Ariver
2026-06-19 b1368b6a51379714958af82cb1e3dc24183b0583
Add horizontal waterfall arrow focus
3 files modified
568 ■■■■■ changed files
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchRootView.swift 438 ●●●●● patch | view | raw | blame | history
C3.tools/round1-horizontal-waterfall-fixture-qa.sh 126 ●●●●● 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.74</string>
    <string>0.0.75</string>
    <key>CFBundleVersion</key>
    <string>20260619.1537</string>
    <string>20260619.1919</string>
    <key>LSMinimumSystemVersion</key>
    <string>26.0</string>
    <key>NSHighResolutionCapable</key>
C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -230,6 +230,32 @@
        case keyboard
    }
    private enum HorizontalKeyboardFocusKind: String {
        case app
        case window
    }
    private struct HorizontalKeyboardFocusNode {
        let kind: HorizontalKeyboardFocusKind
        let appGroupIndex: Int
        let windowIndex: Int?
        let windowID: UInt32?
        let spaceID: UInt64?
        let frame: CGRect
        let order: Int
        var center: CGPoint {
            CGPoint(x: frame.midX, y: frame.midY)
        }
        func matches(_ other: HorizontalKeyboardFocusNode) -> Bool {
            kind == other.kind
                && appGroupIndex == other.appGroupIndex
                && windowIndex == other.windowIndex
                && windowID == other.windowID
        }
    }
    private struct SpaceLaneSegmentLayers {
        let space: QuickSwitchSpaceViewModel
        let containerLayer: CALayer
@@ -1269,6 +1295,7 @@
            "selectedWindowID": effectiveSelection?.windowID ?? NSNull(),
            "keyboardFocusedAppGroupIndex": keyboardFocusedAppGroupIndex ?? NSNull(),
            "keyboardFocusedWindowID": keyboardFocusedWindowID ?? NSNull(),
            "horizontalAppDefaultWindowID": horizontalAppDefaultWindowID ?? NSNull(),
            "keyboardCommandsApplied": keyboardCommandsApplied,
            "lastKeyboardCommand": lastKeyboardCommand ?? NSNull(),
            "lastKeyboardAppIndexCommand": lastKeyboardAppIndexCommand ?? NSNull(),
@@ -1393,7 +1420,7 @@
        case "tab":
            if isHorizontalMasonryMode {
                recordKeyboardCommand("tab")
                moveSelection(.right)
                moveHorizontalMasonryTabSelection()
            } else {
                tabIgnoredCount += 1
                recordKeyboardCommand("tabIgnored")
@@ -1469,6 +1496,9 @@
        clearKeyboardBoundaryFeedback()
        ensureAppShelfItemVisible(appGroupIndex)
        layoutSubtreeIfNeeded()
        if isHorizontalMasonryMode {
            _ = selectHorizontalMasonryDefaultWindowForApp(appGroupIndex)
        }
        hoverAppGroup(appGroupIndex, source: .keyboard)
        needsLayout = true
        return true
@@ -1496,6 +1526,53 @@
    private var appHoverTargetAppGroupIndex: Int? {
        guard case .app(let appGroupIndex) = hoverTarget else { return nil }
        return appGroupIndex
    }
    private var horizontalAppDefaultWindowID: UInt32? {
        guard isHorizontalMasonryMode,
              case .app(let appGroupIndex) = hoverTarget
        else {
            return nil
        }
        return firstHorizontalMasonryCard(for: appGroupIndex)?.item.window.id
    }
    private func firstHorizontalMasonryCard(for appGroupIndex: Int) -> WaterfallCardLayers? {
        waterfallColumns
            .first { $0.column.appGroupIndex == appGroupIndex }?
            .cards
            .sorted { $0.item.windowIndex < $1.item.windowIndex }
            .first
    }
    private func isHorizontalAppDefaultCard(_ card: WaterfallCardLayers) -> Bool {
        guard isHorizontalMasonryMode,
              card.item.window.id == horizontalAppDefaultWindowID
        else {
            return false
        }
        return true
    }
    @discardableResult
    private func selectHorizontalMasonryDefaultWindowForApp(
        _ appGroupIndex: Int,
        recordsSelectionChange: Bool = false
    ) -> QuickSwitchSelection? {
        guard isHorizontalMasonryMode,
              let card = firstHorizontalMasonryCard(for: appGroupIndex)
        else {
            return nil
        }
        let previousSelection = effectiveSelection
        let selection = selectionForCard(card)
        currentSelection = selection
        columnSelectionHistory[selection.appGroupIndex] = selection.windowIndex
        if recordsSelectionChange {
            selectionChangedByLastCommand = previousSelection != selection
        }
        return selection
    }
    private func moveVerticalSelectionWithinFocusedColumn(_ direction: QuickSwitchKeyboardDirection) {
@@ -2040,6 +2117,11 @@
        hoveredSpaceID = target.spaceID
        hoveredSpaceLaneID = target.spaceLaneID
        if case .app(let appGroupIndex) = target, isHorizontalMasonryMode {
            keyboardFocusedWindowID = nil
            _ = selectHorizontalMasonryDefaultWindowForApp(appGroupIndex)
        }
        if alignsWaterfallWithAppShelf, let appGroupIndex = target.appGroupIndex {
            alignWaterfallColumnWithAppShelfIcon(appGroupIndex: appGroupIndex, animated: true)
        }
@@ -2089,29 +2171,360 @@
    }
    private func moveHorizontalMasonrySelection(_ direction: QuickSwitchKeyboardDirection) {
        guard let current = currentSelection ?? currentViewModel?.initialSelection,
              let next = nextHorizontalMasonrySelection(from: current, direction: direction)
        layoutSubtreeIfNeeded()
        let nodes = horizontalKeyboardFocusNodes()
        guard !nodes.isEmpty,
              let current = currentHorizontalKeyboardFocusNode(in: nodes),
              let next = nextHorizontalKeyboardFocusNode(from: current, direction: direction, in: nodes)
        else {
            DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection.blocked", [
                "direction": String(describing: direction),
                "hasNodes": !nodes.isEmpty,
                "hasSelection": (currentSelection ?? currentViewModel?.initialSelection) != nil
            ])
            selectionChangedByLastCommand = false
            return
        }
        focusHorizontalKeyboardNode(next, direction: direction)
        DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection", [
            "direction": String(describing: direction),
            "kind": next.kind.rawValue,
            "appGroupIndex": next.appGroupIndex,
            "windowIndex": next.windowIndex ?? -1,
            "windowID": next.windowID ?? 0
        ])
    }
    private func moveHorizontalMasonryTabSelection() {
        guard let current = currentSelection ?? currentViewModel?.initialSelection,
              let next = nextHorizontalMasonrySelection(from: current, direction: .right)
        else {
            DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonryTabSelection.blocked", [
                "hasSelection": (currentSelection ?? currentViewModel?.initialSelection) != nil
            ])
            selectionChangedByLastCommand = false
            return
        }
        columnSelectionHistory[current.appGroupIndex] = current.windowIndex
        let previousSelection = effectiveSelection
        currentSelection = next
        keyboardFocusedAppGroupIndex = next.appGroupIndex
        keyboardFocusedWindowID = next.windowID
        columnSelectionHistory[next.appGroupIndex] = next.windowIndex
        selectionChangedByLastCommand = previousSelection != next
        clearKeyboardBoundaryFeedback()
        setHoverTarget(
            .window(appGroupIndex: next.appGroupIndex, windowID: next.windowID, spaceID: nil),
            source: .keyboard
        )
        ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
        DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection", [
            "direction": String(describing: direction),
        DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonryTabSelection", [
            "appGroupIndex": next.appGroupIndex,
            "windowIndex": next.windowIndex,
            "windowID": next.windowID
        ])
        needsLayout = true
    }
    private func horizontalKeyboardFocusNodes() -> [HorizontalKeyboardFocusNode] {
        var nodes: [HorizontalKeyboardFocusNode] = []
        nodes.reserveCapacity(appShelfItems.count + waterfallColumns.reduce(0) { $0 + $1.cards.count })
        for (index, item) in appShelfItems.enumerated() {
            let frame = item.iconLayer.frame.offsetBy(
                dx: shelfLayer.frame.minX
                    + appShelfContentLayer.frame.minX
                    + item.containerLayer.frame.minX,
                dy: shelfLayer.frame.minY
                    + appShelfContentLayer.frame.minY
                    + item.containerLayer.frame.minY
            )
            nodes.append(HorizontalKeyboardFocusNode(
                kind: .app,
                appGroupIndex: item.item.appGroupIndex,
                windowIndex: nil,
                windowID: nil,
                spaceID: nil,
                frame: frame,
                order: index
            ))
        }
        var order = appShelfItems.count
        for column in waterfallColumns.sorted(by: { $0.column.appGroupIndex < $1.column.appGroupIndex }) {
            for card in column.cards.sorted(by: { $0.item.windowIndex < $1.item.windowIndex }) {
                let frame = card.containerLayer.frame.offsetBy(
                    dx: waterfallLayer.frame.minX
                        + waterfallContentLayer.frame.minX
                        + column.containerLayer.frame.minX
                        + column.cardsClipLayer.frame.minX,
                    dy: waterfallLayer.frame.minY
                        + waterfallContentLayer.frame.minY
                        + column.containerLayer.frame.minY
                        + column.cardsClipLayer.frame.minY
                )
                nodes.append(HorizontalKeyboardFocusNode(
                    kind: .window,
                    appGroupIndex: card.item.appGroupIndex,
                    windowIndex: card.item.windowIndex,
                    windowID: card.item.window.id,
                    spaceID: card.item.primarySpaceID,
                    frame: frame,
                    order: order
                ))
                order += 1
            }
        }
        return nodes
    }
    private func currentHorizontalKeyboardFocusNode(
        in nodes: [HorizontalKeyboardFocusNode]
    ) -> HorizontalKeyboardFocusNode? {
        if case .app(let appGroupIndex) = hoverTarget,
           let node = nodes.first(where: { $0.kind == .app && $0.appGroupIndex == appGroupIndex }) {
            return node
        }
        if let keyboardFocusedWindowID,
           let node = nodes.first(where: { $0.kind == .window && $0.windowID == keyboardFocusedWindowID }) {
            return node
        }
        if case .window(_, let windowID, _) = hoverTarget,
           let node = nodes.first(where: { $0.kind == .window && $0.windowID == windowID }) {
            return node
        }
        if let selection = effectiveSelection,
           let node = nodes.first(where: { $0.kind == .window && $0.windowID == selection.windowID }) {
            return node
        }
        return nodes.min { $0.order < $1.order }
    }
    private func nextHorizontalKeyboardFocusNode(
        from current: HorizontalKeyboardFocusNode,
        direction: QuickSwitchKeyboardDirection,
        in nodes: [HorizontalKeyboardFocusNode]
    ) -> HorizontalKeyboardFocusNode? {
        let candidates = nodes.filter { !$0.matches(current) }
        guard !candidates.isEmpty else { return current }
        if current.kind == .app,
           direction == .down,
           let firstWindow = firstHorizontalWindowNode(for: current.appGroupIndex, in: nodes) {
            return firstWindow
        }
        if current.kind == .window,
           direction == .up,
           current.windowID == firstHorizontalMasonryCard(for: current.appGroupIndex)?.item.window.id,
           let appNode = nodes.first(where: { $0.kind == .app && $0.appGroupIndex == current.appGroupIndex }) {
            return appNode
        }
        switch direction {
        case .left, .right:
            let sameKindCandidates = candidates.filter { $0.kind == current.kind }
            return nextHorizontalFocusByX(
                from: current,
                direction: direction,
                candidates: sameKindCandidates.isEmpty ? candidates : sameKindCandidates
            )
        case .up, .down:
            return nextHorizontalFocusByY(from: current, direction: direction, candidates: candidates)
        }
    }
    private func firstHorizontalWindowNode(
        for appGroupIndex: Int,
        in nodes: [HorizontalKeyboardFocusNode]
    ) -> HorizontalKeyboardFocusNode? {
        nodes
            .filter { $0.kind == .window && $0.appGroupIndex == appGroupIndex }
            .min { lhs, rhs in
                (lhs.windowIndex ?? .max, lhs.order) < (rhs.windowIndex ?? .max, rhs.order)
            }
    }
    private func nextHorizontalFocusByX(
        from current: HorizontalKeyboardFocusNode,
        direction: QuickSwitchKeyboardDirection,
        candidates: [HorizontalKeyboardFocusNode]
    ) -> HorizontalKeyboardFocusNode? {
        let epsilon: CGFloat = 0.5
        let forward = direction == .right
        let directional = candidates.filter { node in
            forward
                ? node.center.x > current.center.x + epsilon
                : node.center.x < current.center.x - epsilon
        }
        let sameBand = directional.filter { isHorizontalFocusSameBand($0, current) }
        let pool = sameBand.isEmpty ? directional : sameBand
        if let next = pool.sorted(by: horizontalFocusXSort(from: current, forward: forward)).first {
            return next
        }
        let wrapSameBand = candidates.filter { isHorizontalFocusSameBand($0, current) }
        let wrapPool = wrapSameBand.isEmpty ? candidates : wrapSameBand
        return wrapPool.sorted(by: horizontalFocusXWrapSort(from: current, forward: forward)).first
    }
    private func nextHorizontalFocusByY(
        from current: HorizontalKeyboardFocusNode,
        direction: QuickSwitchKeyboardDirection,
        candidates: [HorizontalKeyboardFocusNode]
    ) -> HorizontalKeyboardFocusNode? {
        let epsilon: CGFloat = 0.5
        let upward = direction == .up
        let directional = candidates.filter { node in
            upward
                ? node.center.y > current.center.y + epsilon
                : node.center.y < current.center.y - epsilon
        }
        if let next = directional.sorted(by: horizontalFocusYSort(from: current, upward: upward)).first {
            return next
        }
        return candidates.sorted(by: horizontalFocusYWrapSort(from: current, upward: upward)).first
    }
    private func isHorizontalFocusSameBand(
        _ lhs: HorizontalKeyboardFocusNode,
        _ rhs: HorizontalKeyboardFocusNode
    ) -> Bool {
        let tolerance = max(lhs.frame.height, rhs.frame.height) * 0.75
        return abs(lhs.center.y - rhs.center.y) <= tolerance
    }
    private func horizontalFocusXSort(
        from current: HorizontalKeyboardFocusNode,
        forward: Bool
    ) -> (HorizontalKeyboardFocusNode, HorizontalKeyboardFocusNode) -> Bool {
        { lhs, rhs in
            let lhsPrimary = abs(lhs.center.x - current.center.x)
            let rhsPrimary = abs(rhs.center.x - current.center.x)
            if abs(lhsPrimary - rhsPrimary) > 0.5 {
                return lhsPrimary < rhsPrimary
            }
            let lhsSecondary = abs(lhs.center.y - current.center.y)
            let rhsSecondary = abs(rhs.center.y - current.center.y)
            if abs(lhsSecondary - rhsSecondary) > 0.5 {
                return lhsSecondary < rhsSecondary
            }
            return forward ? lhs.order < rhs.order : lhs.order > rhs.order
        }
    }
    private func horizontalFocusXWrapSort(
        from current: HorizontalKeyboardFocusNode,
        forward: Bool
    ) -> (HorizontalKeyboardFocusNode, HorizontalKeyboardFocusNode) -> Bool {
        { lhs, rhs in
            if abs(lhs.center.x - rhs.center.x) > 0.5 {
                return forward ? lhs.center.x < rhs.center.x : lhs.center.x > rhs.center.x
            }
            let lhsSecondary = abs(lhs.center.y - current.center.y)
            let rhsSecondary = abs(rhs.center.y - current.center.y)
            if abs(lhsSecondary - rhsSecondary) > 0.5 {
                return lhsSecondary < rhsSecondary
            }
            return forward ? lhs.order < rhs.order : lhs.order > rhs.order
        }
    }
    private func horizontalFocusYSort(
        from current: HorizontalKeyboardFocusNode,
        upward: Bool
    ) -> (HorizontalKeyboardFocusNode, HorizontalKeyboardFocusNode) -> Bool {
        { lhs, rhs in
            let lhsPrimary = abs(lhs.center.y - current.center.y)
            let rhsPrimary = abs(rhs.center.y - current.center.y)
            if abs(lhsPrimary - rhsPrimary) > 0.5 {
                return lhsPrimary < rhsPrimary
            }
            let lhsSecondary = abs(lhs.center.x - current.center.x)
            let rhsSecondary = abs(rhs.center.x - current.center.x)
            if abs(lhsSecondary - rhsSecondary) > 0.5 {
                return lhsSecondary < rhsSecondary
            }
            return upward ? lhs.order < rhs.order : lhs.order > rhs.order
        }
    }
    private func horizontalFocusYWrapSort(
        from current: HorizontalKeyboardFocusNode,
        upward: Bool
    ) -> (HorizontalKeyboardFocusNode, HorizontalKeyboardFocusNode) -> Bool {
        { lhs, rhs in
            if abs(lhs.center.y - rhs.center.y) > 0.5 {
                return upward ? lhs.center.y < rhs.center.y : lhs.center.y > rhs.center.y
            }
            let lhsSecondary = abs(lhs.center.x - current.center.x)
            let rhsSecondary = abs(rhs.center.x - current.center.x)
            if abs(lhsSecondary - rhsSecondary) > 0.5 {
                return lhsSecondary < rhsSecondary
            }
            return upward ? lhs.order < rhs.order : lhs.order > rhs.order
        }
    }
    private func focusHorizontalKeyboardNode(
        _ node: HorizontalKeyboardFocusNode,
        direction _: QuickSwitchKeyboardDirection
    ) {
        switch node.kind {
        case .app:
            keyboardFocusedAppGroupIndex = node.appGroupIndex
            keyboardFocusedWindowID = nil
            clearKeyboardBoundaryFeedback()
            ensureAppShelfItemVisible(node.appGroupIndex)
            _ = selectHorizontalMasonryDefaultWindowForApp(
                node.appGroupIndex,
                recordsSelectionChange: true
            )
            hoverAppGroup(node.appGroupIndex, source: .keyboard)
        case .window:
            guard let windowIndex = node.windowIndex,
                  let windowID = node.windowID
            else {
                selectionChangedByLastCommand = false
                return
            }
            let previousSelection = effectiveSelection
            let next = QuickSwitchSelection(
                appGroupIndex: node.appGroupIndex,
                windowIndex: windowIndex,
                windowID: windowID
            )
            keyboardFocusedAppGroupIndex = node.appGroupIndex
            keyboardFocusedWindowID = windowID
            currentSelection = next
            columnSelectionHistory[next.appGroupIndex] = next.windowIndex
            selectionChangedByLastCommand = previousSelection != next
            clearKeyboardBoundaryFeedback()
            ensureAppShelfItemVisible(node.appGroupIndex)
            setHoverTarget(
                .window(appGroupIndex: node.appGroupIndex, windowID: windowID, spaceID: node.spaceID),
                source: .keyboard
            )
            ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
        }
        needsLayout = true
    }
@@ -4403,10 +4816,13 @@
        let isSelected = card.item.window.id == effectiveSelection?.windowID
        let isHovered = card.item.window.id == hoveredWindowID
        let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID
        let isHorizontalAppDefaultFocused = isHorizontalAppDefaultCard(card)
        let isAppLinked = isWaterfallCardAppLinked(card)
        applyWaterfallCardVisualState(
            card,
            selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused,
            selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered)
                || isKeyboardFocused
                || isHorizontalAppDefaultFocused,
            hovered: isHovered,
            appLinked: isAppLinked
        )
@@ -5715,6 +6131,7 @@
                "identifierSource": "\(card.item.window.identifierSource)",
                "isSelected": card.item.window.id == effectiveSelection?.windowID,
                "isKeyboardFocused": card.item.window.id == keyboardFocusedWindowID,
                "isHorizontalAppDefault": isHorizontalAppDefaultCard(card),
                "visualStates": waterfallCardVisualStates(for: card),
                "thumbnailStrategy": thumbnailStrategyString(for: card.item.window),
                "screenshotSource": screenshotSourceString(screenshotSource, for: card.item.window),
@@ -5739,6 +6156,7 @@
                "closeButtonGlyphLineWidth": Double(card.closeButton.glyphLayer.lineWidth),
                "shineVisible": card.shineLayer.opacity > 0,
                "zPosition": Double(card.containerLayer.zPosition),
                "bounds": dictionary(from: card.containerLayer.bounds),
                "frame": dictionary(from: card.containerLayer.frame),
                "visibleFrame": dictionary(from: visibleFrame),
                "titleTruncationMode": "middle"
@@ -5834,8 +6252,11 @@
        let isSelected = card.item.window.id == effectiveSelection?.windowID
        let isHovered = card.item.window.id == hoveredWindowID
        let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID
        let isHorizontalAppDefaultFocused = isHorizontalAppDefaultCard(card)
        let isAppLinked = isWaterfallCardAppLinked(card)
        if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused {
        if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered)
            || isKeyboardFocused
            || isHorizontalAppDefaultFocused {
            states.append("selected")
        } else if isSelected {
            states.append("selectedVisualSuppressed")
@@ -5843,6 +6264,9 @@
        if isKeyboardFocused {
            states.append("keyboardFocused")
        }
        if isHorizontalAppDefaultFocused {
            states.append("appDefault")
        }
        if isHovered {
            states.append("hover")
        }
C3.tools/round1-horizontal-waterfall-fixture-qa.sh
@@ -1,7 +1,8 @@
#!/bin/bash
# Round01.1 horizontal Waterfall fixture QA. It verifies the P1 masonry view:
# no horizontal scroll, centered equal-width fixed-height cards, 150pt vertical
# placement steps, App Shelf anchoring, and horizontal-mode Tab navigation.
# placement steps, App Shelf anchoring, horizontal-mode Tab navigation, and
# horizontal arrow focus across App Shelf icons plus window cards.
set -euo pipefail
@@ -12,11 +13,16 @@
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"
APP_ENTER_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-app-enter-report.json"
ARROW_WRAP_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-arrow-wrap-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:-6}"
HOVER_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_HOVER_APP_INDEX:-7}"
KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_KEY_SEQUENCE:-tab}"
APP_ENTER_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_APP_ENTER_KEY_SEQUENCE:-app:3,enter}"
APP_ENTER_TARGET_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_APP_ENTER_TARGET_APP_INDEX:-2}"
ARROW_WRAP_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_ARROW_WRAP_KEY_SEQUENCE:-app:1,down,up,left,down,enter}"
REPORT_WAIT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_REPORT_WAIT:-6.0}"
fail() {
@@ -125,9 +131,9 @@
require(root.get("hoveredAppGroupIndex") == hover_app_index, "horizontal masonry must keep the hovered App group available to Waterfall visuals")
require(cards, "horizontal masonry fixture must expose cards")
widths = [round(card.get("frame", {}).get("width", 0), 1) for card in cards]
widths = [round(card.get("bounds", {}).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]
heights = [round(card.get("bounds", {}).get("height", 0), 1) for card in cards]
expected_card_height = 138.0
expected_thumbnail_height = 106.0
expected_card_step = 150.0
@@ -181,6 +187,13 @@
require(blue_reference is not None, "fixture must expose a Space Lane blue reference color")
require(all("appLinked" in card.get("visualStates", []) for card in hover_cards), "all cards for the hovered App must expose appLinked visual state")
require(all("appLinked" not in card.get("visualStates", []) for card in non_hover_cards), "non-hovered App cards must not expose appLinked visual state")
require(root.get("selectedAppGroupIndex") == hover_app_index, "hovered App must select its first window as the default submit target")
require(root.get("selectedWindowIndex") == 0, "hovered App default submit target must be its first window")
require(root.get("horizontalAppDefaultWindowID") == hover_first_card.get("windowID"), "hovered App default window ID must match the first card")
require(hover_first_card.get("isSelected") is True, "hovered App first card must become the effective selection")
require(hover_first_card.get("isHorizontalAppDefault") is True, "hovered App first card must expose app default state")
require("appDefault" in hover_first_card.get("visualStates", []), "hovered App first card must report appDefault visual state")
require("selected" in hover_first_card.get("visualStates", []), "hovered App first card must get selected visual state")
require(all(card.get("shadowOpacity", 0) >= 0.15 for card in hover_cards), "hovered App cards must float with visible shadow opacity")
require(all(card.get("shadowRadius", 0) >= 18 for card in hover_cards), "hovered App cards must float with visible shadow radius")
require(all(card.get("shadowOpacity", 1) <= 0.01 for card in non_hover_cards if "spaceFocused" not in card.get("visualStates", []) and "hover" not in card.get("visualStates", []) and "selected" not in card.get("visualStates", [])), "normal non-hovered cards must not inherit App hover shadow")
@@ -248,6 +261,95 @@
PY
}
assert_app_enter_report() {
  /usr/bin/python3 - "$APP_ENTER_REPORT" "$APP_ENTER_KEY_SEQUENCE" "$APP_ENTER_TARGET_APP_INDEX" <<'PY'
import json
import sys
path = sys.argv[1]
key_sequence = [part for part in sys.argv[2].split(",") if part]
target_app_index = int(sys.argv[3])
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", "App Enter run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == key_sequence, "App Enter keyboard commands must be applied")
require(target_app_index < len(columns), "target App index must exist")
target_card = columns[target_app_index].get("cards", [])[0]
require(root.get("hoverTargetKind") == "app", "App index focus must leave hover target on the App icon")
require(root.get("keyboardFocusedAppGroupIndex") == target_app_index, "keyboard App focus must target requested App")
require(root.get("keyboardFocusedWindowID") is None, "App icon focus must not keep stale window keyboard focus")
require(root.get("selectedAppGroupIndex") == target_app_index, "App icon focus must select the target App")
require(root.get("selectedWindowIndex") == 0, "App icon focus must select the first window")
require(root.get("horizontalAppDefaultWindowID") == target_card.get("windowID"), "App default window ID must match the first window")
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "Enter on App icon must commit the target App")
require(root.get("lastCommittedWindowIndex") == 0, "Enter on App icon must commit the first window")
require(root.get("lastCommittedWindowID") == target_card.get("windowID"), "Enter on App icon must commit the first window ID")
require(root.get("lastCommitSource") == "keyboard", "App Enter commit must be keyboard sourced")
require(target_card.get("isHorizontalAppDefault") is True, "target first card must report app default state")
require("appDefault" in target_card.get("visualStates", []), "target first card must expose appDefault visual state")
require("selected" in target_card.get("visualStates", []), "target first card must expose selected visual state")
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "hoverTargetKind": root.get("hoverTargetKind"),
    "targetAppGroupIndex": target_app_index,
    "lastCommittedWindowID": root.get("lastCommittedWindowID")
}, indent=2, ensure_ascii=False))
PY
}
assert_arrow_wrap_report() {
  /usr/bin/python3 - "$ARROW_WRAP_REPORT" "$ARROW_WRAP_KEY_SEQUENCE" "$FIXTURE_APP_COUNT" <<'PY'
import json
import sys
path = sys.argv[1]
key_sequence = [part for part in sys.argv[2].split(",") if part]
fixture_app_count = int(sys.argv[3])
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", [])
target_app_index = fixture_app_count - 1
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(root.get("waterfallViewMode") == "horizontalMasonry", "arrow wrap run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == key_sequence, "arrow wrap keyboard commands must be applied")
require(len(columns) == fixture_app_count, "fixture App count must match")
target_card = columns[target_app_index].get("cards", [])[0]
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "left arrow from first App icon must wrap to the last App")
require(root.get("lastCommittedWindowIndex") == 0, "down arrow from App icon must land on the first window")
require(root.get("lastCommittedWindowID") == target_card.get("windowID"), "Enter after arrow focus must commit the focused first window")
require(root.get("selectedAppGroupIndex") == target_app_index, "selected App must match wrapped App")
require(root.get("selectedWindowIndex") == 0, "selected window must be the wrapped App first window")
require(root.get("lastCommitSource") == "keyboard", "arrow focus Enter commit must be keyboard sourced")
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "wrappedAppGroupIndex": target_app_index,
    "lastCommittedWindowID": root.get("lastCommittedWindowID")
}, indent=2, ensure_ascii=False))
PY
}
assert_filter_report() {
  /usr/bin/python3 - "$FILTER_REPORT" <<'PY'
import json
@@ -281,9 +383,9 @@
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]
widths = [round(card.get("bounds", {}).get("width", 0), 1) for card in cards]
require(len(set(widths)) == 1, "filtered horizontal cards must remain equal width")
heights = [round(card.get("frame", {}).get("height", 0), 1) for card in cards]
heights = [round(card.get("bounds", {}).get("height", 0), 1) for card in cards]
require(len(set(heights)) == 1 and abs(heights[0] - 138.0) <= 1.0, "filtered horizontal cards must keep the fixed 138pt height")
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")
@@ -368,6 +470,20 @@
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
run_fixture "$APP_ENTER_REPORT" --round01-debug-key-sequence="$APP_ENTER_KEY_SEQUENCE"
wait_for_loaded_report "$APP_ENTER_REPORT"
assert_app_enter_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
run_fixture "$ARROW_WRAP_REPORT" --round01-debug-key-sequence="$ARROW_WRAP_KEY_SEQUENCE"
wait_for_loaded_report "$ARROW_WRAP_REPORT"
assert_arrow_wrap_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