From b1368b6a51379714958af82cb1e3dc24183b0583 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 19 Jun 2026 19:21:31 +0800
Subject: [PATCH] Add horizontal waterfall arrow focus

---
 C1.source/Sources/Aligner/QuickSwitchRootView.swift |  438 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 431 insertions(+), 7 deletions(-)

diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index e7676c3..552dca0 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/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")
         }

--
Gitblit v1.9.3