| C1.source/Resources/Aligner-Info.plist | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/Aligner/QuickSwitchRootView.swift | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/Aligner/QuickSwitchSessionController.swift | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift | ●●●●● patch | view | raw | blame | history | |
| C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-horizontal-waterfall-fixture-qa.sh | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-main-ui-qa.sh | ●●●●● 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.56</string> <string>0.0.57</string> <key>CFBundleVersion</key> <string>20260611.2211</string> <string>20260612.1151</string> <key>LSMinimumSystemVersion</key> <string>26.0</string> <key>NSHighResolutionCapable</key> C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -357,6 +357,7 @@ spaceActivationService: quickSwitchSpaceActivationService(), windowCloseService: quickSwitchWindowCloseService(), disableScreenshotRefresh: round1QuickSwitchOptions.disableScreenshotRefresh, waterfallViewMode: quickSwitchWaterfallViewMode(), closeConfirmationRequired: quickSwitchConfirmBeforeClose, onCloseConfirmationDisabled: { [weak self] in self?.setQuickSwitchConfirmBeforeClose(false) @@ -432,6 +433,7 @@ spaceActivationService: quickSwitchSpaceActivationService(), windowCloseService: quickSwitchWindowCloseService(), disableScreenshotRefresh: round1QuickSwitchOptions.disableScreenshotRefresh, waterfallViewMode: quickSwitchWaterfallViewMode(), closeConfirmationRequired: quickSwitchConfirmBeforeClose, onCloseConfirmationDisabled: { [weak self] in self?.setQuickSwitchConfirmBeforeClose(false) @@ -548,6 +550,11 @@ return CGWindowAXWindowService(spaceIDsByWindowIDProvider: { _ in [:] }) } private func quickSwitchWaterfallViewMode() -> QuickSwitchWaterfallViewMode { round1QuickSwitchOptions.waterfallViewMode ?? UserDefaultsPreferenceStore().read().quickSwitchWaterfallViewMode } private func quickSwitchSpaceActivationService() -> any SpaceActivationServiceProtocol { if round1QuickSwitchOptions.debugWindowActivation { return DebugSpaceActivationService() C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -55,9 +55,12 @@ private var focusedSpaceID: UInt64? private var debugHoveredAppGroupIndex: Int? private var waterfallColumns: [WaterfallColumnLayers] = [] private var waterfallViewMode: QuickSwitchWaterfallViewMode = .verticalColumns private var waterfallContentWidth: CGFloat = 0 private var waterfallScrollOffset: CGFloat = 0 private var waterfallColumnScrollOffsets: [Int: CGFloat] = [:] private var horizontalMasonryContentHeight: CGFloat = 0 private var horizontalMasonryScrollOffset: CGFloat = 0 private var currentSelection: QuickSwitchSelection? private var columnSelectionHistory: [Int: Int] = [:] private var keyboardCommandsApplied: [String] = [] @@ -316,6 +319,18 @@ static let horizontalRevealPadding: CGFloat = 0 } private enum HorizontalMasonryMetrics { static let horizontalPadding: CGFloat = 12 static let verticalPadding: CGFloat = 0 static let cardGap: CGFloat = 16 static let cardMinWidth: CGFloat = 220 static let cardMaxWidth: CGFloat = 320 static let titleBarHeight: CGFloat = 26 static let titleGap: CGFloat = 6 static let minThumbnailHeight: CGFloat = 92 static let maxThumbnailHeightFraction: CGFloat = 0.40 } private enum WaterfallHorizontalIntent { case reveal case alignWithAppShelf(animated: Bool) @@ -431,6 +446,15 @@ } if shouldScrollWaterfall(for: event) { if isHorizontalMasonryMode { let currentOffset = horizontalMasonryScrollOffset horizontalMasonryScrollOffset = clampedHorizontalMasonryOffset( currentOffset - event.scrollingDeltaY ) needsLayout = true return } if abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY), waterfallAlignmentScrollable { waterfallScrollOffset = clampedWaterfallAlignmentOffset(waterfallScrollOffset - event.scrollingDeltaX) needsLayout = true @@ -598,6 +622,17 @@ needsLayout = true } func setWaterfallViewMode(_ mode: QuickSwitchWaterfallViewMode) { guard waterfallViewMode != mode else { return } waterfallViewMode = mode waterfallScrollOffset = 0 waterfallColumnScrollOffsets = [:] horizontalMasonryScrollOffset = 0 horizontalMasonryContentHeight = 0 needsLayout = true } func showCloseProgress(for request: QuickSwitchCloseRequest) { setCloseFeedback( CloseFeedback( @@ -715,6 +750,10 @@ lastSpaceFocusedAppGroupIndex = nil lastSpaceLaneClickSpaceID = nil lastAppShelfScrollSyncedAppGroupIndex = nil waterfallScrollOffset = 0 waterfallColumnScrollOffsets = [:] horizontalMasonryScrollOffset = 0 horizontalMasonryContentHeight = 0 focusedSpaceID = nil hoveredCloseTarget = nil pendingCloseTarget = nil @@ -1052,10 +1091,15 @@ "appShelfMinIconSize": Double(AppShelfMetrics.minIconSize), "appShelfItems": appShelfItemReports(), "waterfallFrame": dictionary(from: waterfallLayer.frame), "waterfallViewMode": waterfallViewMode.rawValue, "waterfallContentWidth": Double(waterfallContentWidth), "waterfallVisibleWidth": Double(waterfallLayer.bounds.width), "waterfallScrollOffset": Double(waterfallScrollOffset), "waterfallMaxScrollOffset": Double(waterfallMaxScrollOffset), "horizontalMasonryContentHeight": Double(horizontalMasonryContentHeight), "horizontalMasonryScrollOffset": Double(horizontalMasonryScrollOffset), "horizontalMasonryMaxScrollOffset": Double(horizontalMasonryMaxScrollOffset), "horizontalMasonryScrollable": horizontalMasonryMaxScrollOffset > 0, "waterfallAlignmentMinScrollOffset": Double(waterfallAlignmentScrollRange.lowerBound), "waterfallAlignmentMaxScrollOffset": Double(waterfallAlignmentScrollRange.upperBound), "waterfallScrollable": waterfallMaxScrollOffset > 0, @@ -1167,8 +1211,13 @@ recordKeyboardCommand("enter") commitCurrentSelection() case "tab": tabIgnoredCount += 1 recordKeyboardCommand("tabIgnored") if isHorizontalMasonryMode { recordKeyboardCommand("tab") moveSelection(.right) } else { tabIgnoredCount += 1 recordKeyboardCommand("tabIgnored") } default: recordKeyboardCommand("unknown:\(command)") } @@ -1574,6 +1623,11 @@ } private func moveSelection(_ direction: QuickSwitchKeyboardDirection) { if isHorizontalMasonryMode { moveHorizontalMasonrySelection(direction) return } guard let currentViewModel, let snapshot = snapshotForFocus(from: currentViewModel), let current = currentSelection ?? currentViewModel.initialSelection, @@ -1603,6 +1657,113 @@ "windowID": next.windowID ]) needsLayout = true } private func moveHorizontalMasonrySelection(_ direction: QuickSwitchKeyboardDirection) { guard let current = currentSelection ?? currentViewModel?.initialSelection, let next = nextHorizontalMasonrySelection(from: current, direction: direction) else { DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection.blocked", [ "direction": String(describing: direction), "hasSelection": (currentSelection ?? currentViewModel?.initialSelection) != nil ]) return } columnSelectionHistory[current.appGroupIndex] = current.windowIndex currentSelection = next columnSelectionHistory[next.appGroupIndex] = next.windowIndex ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true)) DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection", [ "direction": String(describing: direction), "appGroupIndex": next.appGroupIndex, "windowIndex": next.windowIndex, "windowID": next.windowID ]) needsLayout = true } private func nextHorizontalMasonrySelection( from current: QuickSwitchSelection, direction: QuickSwitchKeyboardDirection ) -> QuickSwitchSelection? { layoutSubtreeIfNeeded() let cards = horizontalMasonryCardsInPlacementOrder() guard let currentIndex = cards.firstIndex(where: { $0.item.window.id == current.windowID }) else { return cards.first.map(selectionForCard) } switch direction { case .right: return selectionForCard(cards[(currentIndex + 1) % cards.count]) case .left: return selectionForCard(cards[(currentIndex - 1 + cards.count) % cards.count]) case .up: return nearestHorizontalMasonrySelection(from: cards[currentIndex], direction: .up) ?? selectionForCard(cards.first ?? cards[currentIndex]) case .down: return nearestHorizontalMasonrySelection(from: cards[currentIndex], direction: .down) ?? selectionForCard(cards.last ?? cards[currentIndex]) } } private func nearestHorizontalMasonrySelection( from currentCard: WaterfallCardLayers, direction: QuickSwitchKeyboardDirection ) -> QuickSwitchSelection? { let currentFrame = currentCard.containerLayer.frame let candidates = horizontalMasonryCardsInPlacementOrder().filter { card in guard card.item.window.id != currentCard.item.window.id else { return false } switch direction { case .up: return card.containerLayer.frame.minY >= currentFrame.maxY - 0.5 case .down: return card.containerLayer.frame.maxY <= currentFrame.minY + 0.5 case .left, .right: return false } } let best = candidates.min { lhs, rhs in let lhsFrame = lhs.containerLayer.frame let rhsFrame = rhs.containerLayer.frame let lhsPrimaryDistance = direction == .up ? lhsFrame.minY - currentFrame.maxY : currentFrame.minY - lhsFrame.maxY let rhsPrimaryDistance = direction == .up ? rhsFrame.minY - currentFrame.maxY : currentFrame.minY - rhsFrame.maxY if abs(lhsPrimaryDistance - rhsPrimaryDistance) > 0.5 { return lhsPrimaryDistance < rhsPrimaryDistance } let lhsHorizontalDistance = abs(lhsFrame.midX - currentFrame.midX) let rhsHorizontalDistance = abs(rhsFrame.midX - currentFrame.midX) if abs(lhsHorizontalDistance - rhsHorizontalDistance) > 0.5 { return lhsHorizontalDistance < rhsHorizontalDistance } return lhs.item.globalIndex < rhs.item.globalIndex } return best.map(selectionForCard) } private func horizontalMasonryCardsInPlacementOrder() -> [WaterfallCardLayers] { waterfallColumns .sorted { $0.column.appGroupIndex < $1.column.appGroupIndex } .flatMap { column in column.cards.sorted { $0.item.windowIndex < $1.item.windowIndex } } } private func selectionForCard(_ card: WaterfallCardLayers) -> QuickSwitchSelection { QuickSwitchSelection( appGroupIndex: card.item.appGroupIndex, windowIndex: card.item.windowIndex, windowID: card.item.window.id ) } private func commitCurrentSelection() { @@ -1662,6 +1823,16 @@ _ selection: QuickSwitchSelection, horizontalIntent: WaterfallHorizontalIntent = .reveal ) { if isHorizontalMasonryMode { switch horizontalIntent { case .reveal: ensureHorizontalMasonrySelectionVisible(selection, animated: false) case .alignWithAppShelf(let animated): ensureHorizontalMasonrySelectionVisible(selection, animated: animated) } return } guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == selection.appGroupIndex }) else { @@ -1683,6 +1854,11 @@ } private func ensureWaterfallCardVisibleVertically(_ selection: QuickSwitchSelection) { guard !isHorizontalMasonryMode else { ensureHorizontalMasonrySelectionVisible(selection, animated: false) return } let visibleHeight = max( 1, waterfallLayer.bounds.height @@ -1710,6 +1886,11 @@ } private func alignWaterfallColumnWithAppShelfIcon(appGroupIndex: Int, animated: Bool) { if isHorizontalMasonryMode { ensureHorizontalMasonryAppVisible(appGroupIndex, animated: animated) return } guard let item = appShelfItems.first(where: { $0.item.appGroupIndex == appGroupIndex }), let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }) @@ -1736,6 +1917,11 @@ } private func ensureWaterfallColumnVisible(_ appGroupIndex: Int) { if isHorizontalMasonryMode { ensureHorizontalMasonryAppVisible(appGroupIndex, animated: false) return } guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }) else { return } @@ -1744,6 +1930,11 @@ } private func ensureWaterfallColumnVisible(_ column: WaterfallColumnLayers) { if isHorizontalMasonryMode { ensureHorizontalMasonryAppVisible(column.column.appGroupIndex, animated: false) return } waterfallScrollOffset = clampedWaterfallOffset( scrollOffset( makingVisible: column.containerLayer.frame, @@ -1756,6 +1947,10 @@ } private var waterfallAlignmentScrollRange: ClosedRange<CGFloat> { if isHorizontalMasonryMode { return 0...0 } if waterfallShouldKeepFilteredColumnsCentered { return 0...0 } @@ -1822,6 +2017,80 @@ animation.timingFunction = CAMediaTimingFunction(name: WaterfallAlignmentMetrics.animationTimingName) animation.isRemovedOnCompletion = true waterfallContentLayer.add(animation, forKey: "waterfallColumnAlignment") } private func ensureHorizontalMasonrySelectionVisible( _ selection: QuickSwitchSelection, animated: Bool ) { guard let card = waterfallCard( appGroupIndex: selection.appGroupIndex, windowIndex: selection.windowIndex ) else { return } let visibleFrame = card.containerLayer.frame.offsetBy( dx: 0, dy: waterfallContentLayer.frame.minY ) let visibleHeight = waterfallLayer.bounds.height let padding = WaterfallMetrics.revealPadding var nextOffset = horizontalMasonryScrollOffset if visibleFrame.maxY > visibleHeight - padding { nextOffset -= visibleFrame.maxY - (visibleHeight - padding) } else if visibleFrame.minY < padding { nextOffset += padding - visibleFrame.minY } setHorizontalMasonryScrollOffset(nextOffset, animated: animated) } private func ensureHorizontalMasonryAppVisible(_ appGroupIndex: Int, animated: Bool) { guard let firstCard = waterfallColumns .first(where: { $0.column.appGroupIndex == appGroupIndex })? .cards .sorted(by: { $0.item.windowIndex < $1.item.windowIndex }) .first else { return } let cardTopDistance = horizontalMasonryContentHeight - firstCard.containerLayer.frame.maxY setHorizontalMasonryScrollOffset( cardTopDistance - WaterfallMetrics.revealPadding, animated: animated ) } private func setHorizontalMasonryScrollOffset(_ offset: CGFloat, animated: Bool) { let nextOffset = clampedHorizontalMasonryOffset(offset) let previousPositionY = waterfallContentLayer.presentation()?.position.y ?? waterfallContentLayer.position.y horizontalMasonryScrollOffset = nextOffset CATransaction.begin() CATransaction.setDisableActions(true) waterfallContentLayer.frame = horizontalMasonryContentFrame() CATransaction.commit() let nextPositionY = waterfallContentLayer.position.y guard animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, abs(previousPositionY - nextPositionY) > 0.5 else { waterfallContentLayer.removeAnimation(forKey: "horizontalMasonryAlignment") return } let animation = CABasicAnimation(keyPath: "position.y") animation.fromValue = previousPositionY animation.toValue = nextPositionY animation.duration = WaterfallAlignmentMetrics.animationDuration animation.timingFunction = CAMediaTimingFunction(name: WaterfallAlignmentMetrics.animationTimingName) animation.isRemovedOnCompletion = true waterfallContentLayer.add(animation, forKey: "horizontalMasonryAlignment") } private func scrollOffset( @@ -2942,6 +3211,10 @@ return "none" } guard !isHorizontalMasonryMode else { return "visible" } let visibleMinX = waterfallScrollOffset let visibleMaxX = waterfallScrollOffset + waterfallLayer.bounds.width if column.containerLayer.frame.maxX < visibleMinX + WaterfallMetrics.gap { @@ -3024,9 +3297,16 @@ } private func layoutWaterfallColumns() { if isHorizontalMasonryMode { layoutHorizontalMasonryWaterfall() return } guard !waterfallColumns.isEmpty else { waterfallContentWidth = waterfallLayer.bounds.width waterfallScrollOffset = 0 horizontalMasonryContentHeight = waterfallLayer.bounds.height horizontalMasonryScrollOffset = 0 waterfallContentLayer.frame = waterfallLayer.bounds return } @@ -3057,6 +3337,195 @@ } } private func layoutHorizontalMasonryWaterfall() { guard !waterfallColumns.isEmpty else { waterfallContentWidth = waterfallLayer.bounds.width waterfallScrollOffset = 0 horizontalMasonryContentHeight = waterfallLayer.bounds.height horizontalMasonryScrollOffset = 0 waterfallContentLayer.frame = waterfallLayer.bounds return } let layout = horizontalMasonryLayout() waterfallContentWidth = waterfallLayer.bounds.width waterfallScrollOffset = 0 horizontalMasonryContentHeight = layout.contentHeight horizontalMasonryScrollOffset = clampedHorizontalMasonryOffset(horizontalMasonryScrollOffset) waterfallContentLayer.frame = horizontalMasonryContentFrame() for column in waterfallColumns { column.containerLayer.frame = CGRect( x: 0, y: 0, width: waterfallContentWidth, height: horizontalMasonryContentHeight ) column.containerLayer.masksToBounds = false column.containerLayer.backgroundColor = NSColor.clear.cgColor column.containerLayer.borderColor = NSColor.clear.cgColor column.containerLayer.borderWidth = 0 column.containerLayer.shadowOpacity = 0 column.containerLayer.shadowRadius = 0 column.containerLayer.shadowOffset = .zero column.headerLayer.isHidden = true column.appNameLayer.isHidden = true column.countLayer.isHidden = true for card in column.cards { if let frame = layout.cardFramesByWindowID[card.item.window.id] { card.containerLayer.frame = frame } else { card.containerLayer.frame = .zero } layoutWaterfallCard(card) } } } private struct HorizontalMasonryLayout { let contentHeight: CGFloat let cardFramesByWindowID: [UInt32: CGRect] } private func horizontalMasonryLayout() -> HorizontalMasonryLayout { let cards = horizontalMasonryCardsInPlacementOrder() guard !cards.isEmpty else { return HorizontalMasonryLayout( contentHeight: waterfallLayer.bounds.height, cardFramesByWindowID: [:] ) } let viewportWidth = max(1, waterfallLayer.bounds.width) let availableWidth = max( HorizontalMasonryMetrics.cardMinWidth, viewportWidth - HorizontalMasonryMetrics.horizontalPadding * 2 ) let maximumLaneCount = max( 1, Int( floor( (availableWidth + HorizontalMasonryMetrics.cardGap) / (HorizontalMasonryMetrics.cardMinWidth + HorizontalMasonryMetrics.cardGap) ) ) ) let laneCount = min(maximumLaneCount, max(1, cards.count)) let fittingWidth = ( availableWidth - CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.cardGap ) / CGFloat(laneCount) let cardWidth = floor( min( HorizontalMasonryMetrics.cardMaxWidth, max(HorizontalMasonryMetrics.cardMinWidth, fittingWidth) ) ) let usedWidth = CGFloat(laneCount) * cardWidth + CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.cardGap let startX = max( HorizontalMasonryMetrics.horizontalPadding, (viewportWidth - usedWidth) / 2 ) var laneHeights = Array(repeating: CGFloat(0), count: laneCount) var plannedFrames: [(windowID: UInt32, x: CGFloat, top: CGFloat, width: CGFloat, height: CGFloat)] = [] for card in cards { let laneIndex = laneHeights.enumerated().min { lhs, rhs in if abs(lhs.element - rhs.element) > 0.5 { return lhs.element < rhs.element } return lhs.offset < rhs.offset }?.offset ?? 0 let cardHeight = horizontalMasonryCardHeight( for: card, width: cardWidth, viewportHeight: waterfallLayer.bounds.height ) let x = startX + CGFloat(laneIndex) * (cardWidth + HorizontalMasonryMetrics.cardGap) let top = HorizontalMasonryMetrics.verticalPadding + laneHeights[laneIndex] plannedFrames.append(( windowID: card.item.window.id, x: x, top: top, width: cardWidth, height: cardHeight )) laneHeights[laneIndex] += cardHeight + HorizontalMasonryMetrics.cardGap } let tallestLane = laneHeights.max().map { max(0, $0 - HorizontalMasonryMetrics.cardGap) } ?? 0 let contentHeight = max( waterfallLayer.bounds.height, HorizontalMasonryMetrics.verticalPadding * 2 + tallestLane ) let frames = Dictionary( uniqueKeysWithValues: plannedFrames.map { planned in ( planned.windowID, CGRect( x: planned.x, y: contentHeight - planned.top - planned.height, width: planned.width, height: planned.height ) ) } ) return HorizontalMasonryLayout(contentHeight: contentHeight, cardFramesByWindowID: frames) } private func horizontalMasonryCardHeight( for card: WaterfallCardLayers, width: CGFloat, viewportHeight: CGFloat ) -> CGFloat { let thumbnailHeight = min( max( HorizontalMasonryMetrics.minThumbnailHeight, width / horizontalMasonryThumbnailAspectRatio(for: card) ), max(1, viewportHeight * HorizontalMasonryMetrics.maxThumbnailHeightFraction) ) return HorizontalMasonryMetrics.titleBarHeight + HorizontalMasonryMetrics.titleGap + thumbnailHeight } private func horizontalMasonryThumbnailAspectRatio(for card: WaterfallCardLayers) -> CGFloat { if card.item.window.isFullscreen { return 16.0 / 9.0 } if card.item.window.isMinimized { return 4.0 / 3.0 } switch Int(card.item.window.id % 4) { case 0: return 16.0 / 9.0 case 1: return 3.0 / 2.0 case 2: return 4.0 / 3.0 default: return 5.0 / 4.0 } } private func horizontalMasonryContentFrame() -> CGRect { let contentHeight = max(waterfallLayer.bounds.height, horizontalMasonryContentHeight) return CGRect( x: 0, y: waterfallLayer.bounds.height - contentHeight + horizontalMasonryScrollOffset, width: waterfallLayer.bounds.width, height: contentHeight ) } private func waterfallColumnStartX(columnsWidth: CGFloat) -> CGFloat { guard currentViewModel?.lockedSpaceID != nil else { return WaterfallMetrics.contentHorizontalPadding @@ -3067,6 +3536,16 @@ } private func layoutWaterfallColumn(_ column: WaterfallColumnLayers) { column.containerLayer.cornerRadius = 14 column.containerLayer.masksToBounds = true column.containerLayer.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.56).cgColor column.containerLayer.shadowOpacity = 0 column.containerLayer.shadowRadius = 0 column.containerLayer.shadowOffset = .zero column.headerLayer.isHidden = false column.appNameLayer.isHidden = false column.countLayer.isHidden = false let bounds = column.containerLayer.bounds let isSpaceFocused = spaceFocusWindowCount(for: column.column.appGroupIndex) > 0 let isHovered = column.column.appGroupIndex == effectiveHoveredAppGroupIndex @@ -3787,15 +4266,30 @@ ) } private var isHorizontalMasonryMode: Bool { waterfallViewMode == .horizontalMasonry } private var waterfallMaxScrollOffset: CGFloat { max(0, waterfallContentWidth - waterfallLayer.bounds.width) guard !isHorizontalMasonryMode else { return 0 } return max(0, waterfallContentWidth - waterfallLayer.bounds.width) } private func clampedWaterfallOffset(_ offset: CGFloat) -> CGFloat { min(max(0, offset), waterfallMaxScrollOffset) } private var horizontalMasonryMaxScrollOffset: CGFloat { max(0, horizontalMasonryContentHeight - waterfallLayer.bounds.height) } private func clampedHorizontalMasonryOffset(_ offset: CGFloat) -> CGFloat { min(max(0, offset), horizontalMasonryMaxScrollOffset) } private func waterfallMaxVerticalScrollOffset(for appGroupIndex: Int) -> CGFloat { guard !isHorizontalMasonryMode else { return 0 } guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }) else { C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -19,6 +19,7 @@ private let windowCloseService: any WindowCloseServiceProtocol private let systemCriticalWindowDetector: any SystemCriticalWindowDetecting private let disableScreenshotRefresh: Bool private let waterfallViewMode: QuickSwitchWaterfallViewMode private var closeConfirmationRequired: Bool private let onCloseConfirmationDisabled: (() -> Void)? private let debugOverlayWidth: CGFloat? @@ -120,6 +121,7 @@ ), systemCriticalWindowDetector: any SystemCriticalWindowDetecting = CGWindowSystemCriticalWindowDetector(), disableScreenshotRefresh: Bool = false, waterfallViewMode: QuickSwitchWaterfallViewMode = .verticalColumns, closeConfirmationRequired: Bool = true, onCloseConfirmationDisabled: (() -> Void)? = nil, debugHoveredAppGroupIndex: Int? = nil, @@ -135,6 +137,7 @@ self.windowCloseService = windowCloseService self.systemCriticalWindowDetector = systemCriticalWindowDetector self.disableScreenshotRefresh = disableScreenshotRefresh self.waterfallViewMode = waterfallViewMode self.closeConfirmationRequired = closeConfirmationRequired self.onCloseConfirmationDisabled = onCloseConfirmationDisabled self.debugOverlayWidth = debugOverlayWidth @@ -219,6 +222,7 @@ self.onCloseConfirmationDisabled?() } view.setCloseConfirmationRequired(closeConfirmationRequired) view.setWaterfallViewMode(waterfallViewMode) view.apply(viewModel: nil) view.beginPerformanceFirstFrameMeasurement() coordinator.openQuickSwitch() @@ -1461,6 +1465,7 @@ let debugWindowActivation: Bool let debugWindowClose: Bool let disableScreenshotRefresh: Bool let waterfallViewMode: QuickSwitchWaterfallViewMode? let lifecycleCycles: Int? let lifecycleInterval: TimeInterval let lifecycleVisibleDuration: TimeInterval @@ -1488,6 +1493,7 @@ debugWindowActivation: arguments.contains("--round01-debug-window-activation"), debugWindowClose: arguments.contains("--round01-debug-window-close"), disableScreenshotRefresh: arguments.contains("--round01-disable-screenshot-refresh"), waterfallViewMode: waterfallViewModeValue(for: "--round01-waterfall-view-mode", in: arguments), lifecycleCycles: intValue(for: "--round01-quick-switch-lifecycle-cycles", in: arguments), lifecycleInterval: timeInterval(for: "--round01-quick-switch-lifecycle-interval", in: arguments) ?? 0.02, lifecycleVisibleDuration: timeInterval(for: "--round01-quick-switch-lifecycle-visible-duration", in: arguments) ?? 0.16 @@ -1512,6 +1518,22 @@ return String(argument.dropFirst(prefix.count)) } private static func waterfallViewModeValue( for key: String, in arguments: [String] ) -> QuickSwitchWaterfallViewMode? { guard let value = stringValue(for: key, in: arguments) else { return nil } switch value { case "vertical", "vertical-columns", "verticalColumns": return .verticalColumns case "horizontal", "horizontal-masonry", "horizontalMasonry": return .horizontalMasonry default: return nil } } private static func intValue(for key: String, in arguments: [String]) -> Int? { stringValue(for: key, in: arguments).flatMap(Int.init) } C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift
@@ -4,6 +4,7 @@ case schemaVersion = "schemaVersion" case showMinimizedWindows = "quickSwitch.showMinimizedWindows" case showFullscreenWindows = "quickSwitch.showFullscreenWindows" case quickSwitchWaterfallViewMode = "quickSwitch.waterfallViewMode" case theme = "appearance.theme" case language = "appearance.language" case spaceDisplayStrategy = "spaces.displayStrategy" @@ -30,9 +31,15 @@ case currentSpaceOnly } public enum QuickSwitchWaterfallViewMode: String, CaseIterable, Equatable, Sendable { case verticalColumns case horizontalMasonry } public struct AlignerPreferences: Equatable, Sendable { public let showMinimizedWindows: Bool public let showFullscreenWindows: Bool public let quickSwitchWaterfallViewMode: QuickSwitchWaterfallViewMode public let theme: ThemePreference public let language: LanguagePreference public let spaceDisplayStrategy: SpaceDisplayStrategy @@ -40,12 +47,14 @@ public init( showMinimizedWindows: Bool, showFullscreenWindows: Bool, quickSwitchWaterfallViewMode: QuickSwitchWaterfallViewMode, theme: ThemePreference, language: LanguagePreference, spaceDisplayStrategy: SpaceDisplayStrategy ) { self.showMinimizedWindows = showMinimizedWindows self.showFullscreenWindows = showFullscreenWindows self.quickSwitchWaterfallViewMode = quickSwitchWaterfallViewMode self.theme = theme self.language = language self.spaceDisplayStrategy = spaceDisplayStrategy @@ -53,11 +62,12 @@ } public enum PreferenceSchema { public static let version = 1 public static let version = 2 public static let defaults = AlignerPreferences( showMinimizedWindows: true, showFullscreenWindows: true, quickSwitchWaterfallViewMode: .verticalColumns, theme: .system, language: .system, spaceDisplayStrategy: .includeVisibleAndFullscreen @@ -68,6 +78,7 @@ PreferenceKey.schemaVersion.storageKey: version, PreferenceKey.showMinimizedWindows.storageKey: defaults.showMinimizedWindows, PreferenceKey.showFullscreenWindows.storageKey: defaults.showFullscreenWindows, PreferenceKey.quickSwitchWaterfallViewMode.storageKey: defaults.quickSwitchWaterfallViewMode.rawValue, PreferenceKey.theme.storageKey: defaults.theme.rawValue, PreferenceKey.language.storageKey: defaults.language.rawValue, PreferenceKey.spaceDisplayStrategy.storageKey: defaults.spaceDisplayStrategy.rawValue @@ -110,6 +121,10 @@ return AlignerPreferences( showMinimizedWindows: userDefaults.bool(forKey: PreferenceKey.showMinimizedWindows.storageKey), showFullscreenWindows: userDefaults.bool(forKey: PreferenceKey.showFullscreenWindows.storageKey), quickSwitchWaterfallViewMode: rawValue( for: .quickSwitchWaterfallViewMode, default: PreferenceSchema.defaults.quickSwitchWaterfallViewMode ), theme: rawValue(for: .theme, default: PreferenceSchema.defaults.theme), language: rawValue(for: .language, default: PreferenceSchema.defaults.language), spaceDisplayStrategy: rawValue( @@ -123,6 +138,10 @@ userDefaults.set(PreferenceSchema.version, forKey: PreferenceKey.schemaVersion.storageKey) userDefaults.set(preferences.showMinimizedWindows, forKey: PreferenceKey.showMinimizedWindows.storageKey) userDefaults.set(preferences.showFullscreenWindows, forKey: PreferenceKey.showFullscreenWindows.storageKey) userDefaults.set( preferences.quickSwitchWaterfallViewMode.rawValue, forKey: PreferenceKey.quickSwitchWaterfallViewMode.storageKey ) userDefaults.set(preferences.theme.rawValue, forKey: PreferenceKey.theme.storageKey) userDefaults.set(preferences.language.rawValue, forKey: PreferenceKey.language.storageKey) userDefaults.set(preferences.spaceDisplayStrategy.rawValue, forKey: PreferenceKey.spaceDisplayStrategy.storageKey) C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -873,6 +873,7 @@ "schemaVersion", "quickSwitch.showMinimizedWindows", "quickSwitch.showFullscreenWindows", "quickSwitch.waterfallViewMode", "appearance.theme", "appearance.language", "spaces.displayStrategy" @@ -885,9 +886,10 @@ } func testDefaultPreferencesMatchRound0Decisions() { XCTAssertEqual(PreferenceSchema.version, 1) XCTAssertEqual(PreferenceSchema.version, 2) XCTAssertEqual(PreferenceSchema.defaults.showMinimizedWindows, true) XCTAssertEqual(PreferenceSchema.defaults.showFullscreenWindows, true) XCTAssertEqual(PreferenceSchema.defaults.quickSwitchWaterfallViewMode, .verticalColumns) XCTAssertEqual(PreferenceSchema.defaults.theme, .system) XCTAssertEqual(PreferenceSchema.defaults.language, .system) XCTAssertEqual(PreferenceSchema.defaults.spaceDisplayStrategy, .includeVisibleAndFullscreen) @@ -908,6 +910,7 @@ let preferences = AlignerPreferences( showMinimizedWindows: false, showFullscreenWindows: false, quickSwitchWaterfallViewMode: .horizontalMasonry, theme: .dark, language: .simplifiedChinese, spaceDisplayStrategy: .currentSpaceOnly @@ -945,12 +948,14 @@ userDefaults.set("not-a-theme", forKey: PreferenceKey.theme.storageKey) userDefaults.set("not-a-language", forKey: PreferenceKey.language.storageKey) userDefaults.set("not-a-space-strategy", forKey: PreferenceKey.spaceDisplayStrategy.storageKey) userDefaults.set("not-a-waterfall-mode", forKey: PreferenceKey.quickSwitchWaterfallViewMode.storageKey) let preferences = UserDefaultsPreferenceStore(userDefaults: userDefaults).read() XCTAssertEqual(preferences.theme, PreferenceSchema.defaults.theme) XCTAssertEqual(preferences.language, PreferenceSchema.defaults.language) XCTAssertEqual(preferences.spaceDisplayStrategy, PreferenceSchema.defaults.spaceDisplayStrategy) XCTAssertEqual(preferences.quickSwitchWaterfallViewMode, PreferenceSchema.defaults.quickSwitchWaterfallViewMode) } @MainActor C3.tools/round1-horizontal-waterfall-fixture-qa.sh
New file @@ -0,0 +1,323 @@ #!/bin/bash # Round01.1 horizontal Waterfall fixture QA. It verifies the P1 masonry view: # no horizontal scroll, centered equal-width cards, variable heights, thumbnail # 40% max-height, App Shelf anchoring, and horizontal-mode Tab navigation. 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" LAYOUT_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-layout-report.json" KEYBOARD_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-keyboard-report.json" FILTER_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-filter-report.json" FIXTURE_APP_COUNT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_APP_COUNT:-8}" FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_WINDOWS_PER_APP:-4}" HOVER_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_HOVER_APP_INDEX:-7}" KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_KEY_SEQUENCE:-tab}" REPORT_WAIT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_REPORT_WAIT:-6.0}" fail() { echo "Round01.1 horizontal Waterfall fixture QA failed: $*" >&2 exit 1 } 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" } wait_for_loaded_report() { local report="$1" /usr/bin/python3 - "$report" "$REPORT_WAIT" <<'PY' import json import sys import time path = sys.argv[1] timeout = float(sys.argv[2]) 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 root.get("waterfallViewMode") == "horizontalMasonry": 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"horizontal Waterfall report did not become loaded within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_layout_report() { /usr/bin/python3 - "$LAYOUT_REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$HOVER_APP_INDEX" <<'PY' import json import sys path = sys.argv[1] fixture_app_count = int(sys.argv[2]) fixture_windows_per_app = int(sys.argv[3]) hover_app_index = int(sys.argv[4]) 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", []) items = root.get("appShelfItems", []) waterfall_frame = root.get("waterfallFrame", {}) waterfall_height = waterfall_frame.get("height", 0) waterfall_width = root.get("waterfallVisibleWidth", 0) cards = [card for column in columns for card in column.get("cards", [])] require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is True, "layout run must keep Quick Switch visible") require(root.get("waterfallViewMode") == "horizontalMasonry", "Waterfall view mode must be horizontalMasonry") require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count") require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match requested shape") require(root.get("waterfallColumnCount") == fixture_app_count, "Waterfall app section count must match app count") require(len(columns) == fixture_app_count, "Waterfall column reports must match app count") require(len(items) == fixture_app_count, "App Shelf item reports must match app count") require(root.get("waterfallColumnNames") == root.get("appShelfNames"), "Waterfall app order must match App Shelf order") require(root.get("waterfallScrollable") is False, "horizontal masonry must not expose horizontal scroll") require(root.get("waterfallMaxScrollOffset") == 0, "horizontal masonry horizontal max scroll offset must be zero") require(abs(root.get("waterfallContentWidth", 0) - waterfall_width) <= 1.0, "horizontal masonry content width must equal visible width") require(root.get("horizontalMasonryScrollable") is True, "fixture must make horizontal masonry vertically scrollable") require(root.get("horizontalMasonryMaxScrollOffset", 0) > 0, "horizontal masonry must expose positive vertical max scroll offset") require(root.get("horizontalMasonryScrollOffset", 0) > 0, "hovering a later App must anchor masonry vertically") require(root.get("appShelfHoveredIndex") == hover_app_index, "debug hover must mark the requested App Shelf item") require(cards, "horizontal masonry fixture must expose cards") widths = [round(card.get("frame", {}).get("width", 0), 1) for card in cards] require(len(set(widths)) == 1, "horizontal masonry cards must be equal width") heights = [round(card.get("frame", {}).get("height", 0), 1) for card in cards] require(len(set(heights)) >= 2, "horizontal masonry cards must expose variable heights") require(all(card.get("frame", {}).get("x", -1) >= -1 for card in cards), "cards must not overflow left") require(all(card.get("frame", {}).get("x", 0) + card.get("frame", {}).get("width", 0) <= waterfall_width + 1 for card in cards), "cards must not overflow right") require(all(card.get("thumbnailFrame", {}).get("height", 0) <= waterfall_height * 0.40 + 1.0 for card in cards), "thumbnail height must be capped to 40% of Waterfall height") require(all(card.get("titleBarFrame", {}).get("y", 0) >= card.get("thumbnailFrame", {}).get("y", 0) + card.get("thumbnailFrame", {}).get("height", 0) for card in cards), "title bars must remain above thumbnails") for expected_index, column in enumerate(columns): require(column.get("appGroupIndex") == expected_index, "appGroupIndex must remain sequential") require(column.get("windowCount") == fixture_windows_per_app, "each fixture App must keep its windows") column_cards = column.get("cards", []) require([card.get("windowIndex") for card in column_cards] == list(range(fixture_windows_per_app)), "window order must remain stable within each App") hover_column = columns[hover_app_index] hover_first_card = hover_column.get("cards", [])[0] hover_visible = hover_first_card.get("visibleFrame", {}) require(hover_visible.get("y", -9999) < waterfall_height + 1, "hovered App first card should be brought into vertical viewport") require(hover_visible.get("y", 0) + hover_visible.get("height", 0) > -1, "hovered App first card should not be fully below viewport") print(json.dumps({ "mode": root.get("waterfallViewMode"), "appCount": report.get("appCount"), "windowCount": report.get("windowCount"), "cardWidth": widths[0], "distinctHeights": sorted(set(heights))[:6], "horizontalMasonryScrollOffset": root.get("horizontalMasonryScrollOffset"), "horizontalMasonryMaxScrollOffset": root.get("horizontalMasonryMaxScrollOffset") }, indent=2, ensure_ascii=False)) PY } assert_keyboard_report() { /usr/bin/python3 - "$KEYBOARD_REPORT" "$KEY_SEQUENCE" <<'PY' import json import sys path = sys.argv[1] key_sequence = [part for part in sys.argv[2].split(",") if part] 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", []) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(root.get("waterfallViewMode") == "horizontalMasonry", "keyboard run must use horizontalMasonry") require(root.get("keyboardCommandsApplied") == key_sequence, "horizontal keyboard commands must be applied") require(root.get("tabIgnoredCount") == 0, "Tab must navigate in horizontal masonry mode") require(root.get("selectedAppGroupIndex") == 0, "single Tab from first card must stay in first App") require(root.get("selectedWindowIndex") == 1, "single Tab from first card must select the second laid card") selected = columns[0].get("cards", [])[1] require(root.get("selectedWindowID") == selected.get("windowID"), "selectedWindowID must match the second laid card") print(json.dumps({ "commands": root.get("keyboardCommandsApplied"), "tabIgnoredCount": root.get("tabIgnoredCount"), "selectedAppGroupIndex": root.get("selectedAppGroupIndex"), "selectedWindowIndex": root.get("selectedWindowIndex") }, indent=2, ensure_ascii=False)) PY } assert_filter_report() { /usr/bin/python3 - "$FILTER_REPORT" <<'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("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is True, "Space filter run must keep Quick Switch visible") require(root.get("waterfallViewMode") == "horizontalMasonry", "Space filter run must use horizontalMasonry") require(root.get("spaceFilterActive") is True, "clicking Space A1 must lock the filter") require(root.get("spaceFilterLockedSpaceID") == 1, "Space A1 must be the locked Space") require(root.get("appShelfNames") == ["Alpha Space App", "Beta Space App"], "horizontal Space filter must filter App Shelf") require(root.get("waterfallColumnNames") == ["Alpha Space App", "Beta Space App"], "horizontal Space filter must filter Waterfall sections") require([card.get("windowID") for card in cards] == [50101, 50102, 50201], "horizontal Space filter must expose only Space A1 windows") require(all(card.get("primarySpaceID") == 1 for card in cards), "all filtered horizontal cards must belong to Space A1") require(root.get("waterfallScrollable") is False, "horizontal Space filter must not expose horizontal scroll") require(root.get("projectionTransitionFadeOutLayerCount", 0) > 0, "horizontal Space filter must animate removed cards/apps") require(root.get("projectionTransitionDurationMilliseconds", 0) >= 120, "horizontal Space filter must report projection duration") widths = [round(card.get("frame", {}).get("width", 0), 1) for card in cards] require(len(set(widths)) == 1, "filtered horizontal cards must remain equal width") visible_width = root.get("waterfallVisibleWidth", 0) require(all(card.get("frame", {}).get("x", -1) >= -1 for card in cards), "filtered cards must not overflow left") require(all(card.get("frame", {}).get("x", 0) + card.get("frame", {}).get("width", 0) <= visible_width + 1 for card in cards), "filtered cards must not overflow right") print(json.dumps({ "mode": root.get("waterfallViewMode"), "lockedSpaceID": root.get("spaceFilterLockedSpaceID"), "apps": root.get("appShelfNames"), "windowIDs": [card.get("windowID") for card in cards], "fadeOutLayerCount": root.get("projectionTransitionFadeOutLayerCount"), "transitionDurationMilliseconds": root.get("projectionTransitionDurationMilliseconds") }, indent=2, ensure_ascii=False)) PY } run_fixture() { local report="$1" shift rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-app-count="$FIXTURE_APP_COUNT" \ --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \ --round01-disable-screenshot-refresh \ --round01-waterfall-view-mode=horizontal \ --round01-quick-switch-report="$report" \ "$@" & APP_PID=$! } run_space_filter_fixture() { local report="$1" rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-space-filter \ --round01-disable-screenshot-refresh \ --round01-waterfall-view-mode=horizontal \ --round01-debug-mouse-sequence="click-space:1" \ --round01-quick-switch-report="$report" & APP_PID=$! } if [ "$FIXTURE_APP_COUNT" -le "$HOVER_APP_INDEX" ]; then fail "hover App index must be inside fixture app count" fi if [ "$FIXTURE_WINDOWS_PER_APP" -lt 2 ]; then fail "fixture must include at least two windows per App for Tab navigation" fi stop_current_aligner "$SCRIPT_DIR/package-app.sh" >&2 APP_PID="" cleanup() { if [ -n "${APP_PID:-}" ]; then kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true fi stop_current_aligner } trap cleanup EXIT run_fixture "$LAYOUT_REPORT" --round01-debug-mouse-sequence="hover-app:$HOVER_APP_INDEX" wait_for_loaded_report "$LAYOUT_REPORT" assert_layout_report kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true stop_current_aligner run_fixture "$KEYBOARD_REPORT" --round01-debug-key-sequence="$KEY_SEQUENCE" wait_for_loaded_report "$KEYBOARD_REPORT" assert_keyboard_report kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true stop_current_aligner run_space_filter_fixture "$FILTER_REPORT" wait_for_loaded_report "$FILTER_REPORT" assert_filter_report kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true trap - EXIT stop_current_aligner echo "Round01.1 horizontal Waterfall fixture QA passed" C3.tools/round1-main-ui-qa.sh
@@ -108,6 +108,7 @@ run_step window-activation "$SCRIPT_DIR/round1-window-activation-fixture-qa.sh" run_step mouse-interaction "$SCRIPT_DIR/round1-mouse-interaction-fixture-qa.sh" run_step app-column-alignment "$SCRIPT_DIR/round1-app-column-alignment-fixture-qa.sh" run_step horizontal-waterfall "$SCRIPT_DIR/round1-horizontal-waterfall-fixture-qa.sh" run_step no-quick-switch-residual swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch assert_no_current_aligner