From 63128a870cbedb841cb19cdfbf2bf54e3dfbc25c Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 12 Jun 2026 11:55:58 +0800
Subject: [PATCH] Implement horizontal Quick Switch waterfall
---
C3.tools/round1-main-ui-qa.sh | 1
C3.tools/round1-horizontal-waterfall-fixture-qa.sh | 323 +++++++++++++++++++++
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 500 +++++++++++++++++++++++++++++++++
C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift | 21 +
C1.source/Resources/Aligner-Info.plist | 4
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | 7
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 22 +
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift | 7
8 files changed, 878 insertions(+), 7 deletions(-)
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index 6026e5d..ce77d23 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.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>
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index 4fcad5f..f021a10 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/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()
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 4f8f371..e001bc4 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/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 {
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index 03e9501..51ff663 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/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)
}
diff --git a/C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift b/C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift
index 264f57b..716112b 100644
--- a/C1.source/Sources/AlignerCore/Preferences/AlignerPreferences.swift
+++ b/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)
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index e583820..3c1be22 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/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
diff --git a/C3.tools/round1-horizontal-waterfall-fixture-qa.sh b/C3.tools/round1-horizontal-waterfall-fixture-qa.sh
new file mode 100755
index 0000000..712b8af
--- /dev/null
+++ b/C3.tools/round1-horizontal-waterfall-fixture-qa.sh
@@ -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"
diff --git a/C3.tools/round1-main-ui-qa.sh b/C3.tools/round1-main-ui-qa.sh
index f12f98a..2652fab 100755
--- a/C3.tools/round1-main-ui-qa.sh
+++ b/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
--
Gitblit v1.9.3