From 4e9fef02a574f16bb6fe3f7cef4f967e009d2a5d Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 18 Jun 2026 20:48:55 +0800
Subject: [PATCH] Add App Shelf index markers

---
 C3.tools/round1-app-shelf-fixture-qa.sh                      |   14 +++
 C1.source/Sources/Aligner/QuickSwitchRootView.swift          |  144 +++++++++++++++++++++++++++++++++++
 C1.source/Resources/Aligner-Info.plist                       |    4 
 C1.source/Sources/Aligner/QuickSwitchSessionController.swift |   22 +++++
 4 files changed, 180 insertions(+), 4 deletions(-)

diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index 705b1be..1ef8019 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.69</string>
+    <string>0.0.70</string>
 	<key>CFBundleVersion</key>
-    <string>20260614.2031</string>
+    <string>20260618.2044</string>
 	<key>LSMinimumSystemVersion</key>
 	<string>26.0</string>
 	<key>NSHighResolutionCapable</key>
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 9b47c99..f127082 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -241,6 +241,7 @@
         let item: QuickSwitchAppShelfItemViewModel
         let containerLayer: CALayer
         let backgroundLayer: CALayer
+        let indexLayer: CATextLayer
         let iconLayer: CALayer
         let labelLayer: CATextLayer
         let badgeLayer: CATextLayer
@@ -299,6 +300,10 @@
         static let normalGap: CGFloat = 16
         static let minGap: CGFloat = 8
         static let maxRows = 2
+        static let indexFontSize: CGFloat = 15
+        static let compressedIndexFontSize: CGFloat = 13
+        static let indexWidth: CGFloat = 16
+        static let indexHeight: CGFloat = 18
     }
 
     private enum RootLayoutMetrics {
@@ -751,11 +756,38 @@
         apply(viewModel: viewModel, preservingInteractionState: true, animatedProjection: true)
     }
 
+    func hoverNextAppGroupIndex(cycleForward: Bool = true) -> Bool {
+        guard !appShelfItems.isEmpty else {
+            return false
+        }
+
+        let orderedIndexes = appShelfItems.map(\.item.appGroupIndex)
+        let sortedIndexes = orderedIndexes
+
+        var currentIndexPosition: Int?
+        if let currentHovered = effectiveHoveredAppGroupIndex {
+            currentIndexPosition = sortedIndexes.firstIndex(of: currentHovered)
+        }
+
+        let nextPosition = currentIndexPosition.map { current in
+            if cycleForward {
+                (current + 1) % sortedIndexes.count
+            } else {
+                (current - 1 + sortedIndexes.count) % sortedIndexes.count
+            }
+        } ?? 0
+
+        let nextAppGroupIndex = sortedIndexes[nextPosition]
+        hoverAppGroup(nextAppGroupIndex)
+        return true
+    }
+
     private func apply(
         viewModel: QuickSwitchViewModel?,
         preservingInteractionState: Bool,
         animatedProjection: Bool
     ) {
+        let preservedWindowThumbnails = preservingInteractionState ? cachedThumbnailsByWindowID() : [:]
         let transitionSnapshot = animatedProjection ? captureProjectionTransitionSnapshot() : nil
         if !animatedProjection {
             resetProjectionTransitionReport()
@@ -816,12 +848,35 @@
         rebuildSpaceLaneSegments()
         rebuildAppShelfItems()
         rebuildWaterfallColumns()
+
+        if preservingInteractionState {
+            restoreThumbnailsFromCache(preservedWindowThumbnails)
+        }
+
         needsLayout = true
         ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: false))
         needsLayout = true
         if animatedProjection, let transitionSnapshot {
             layoutSubtreeIfNeeded()
             applyProjectionTransitionAnimations(from: transitionSnapshot)
+        }
+    }
+
+    private func cachedThumbnailsByWindowID() -> [UInt32: Any] {
+        var snapshots: [UInt32: Any] = [:]
+
+        for card in waterfallCardsByWindowID().values {
+            snapshots[card.item.window.id] = card.thumbnailLayer.contents
+        }
+
+        return snapshots
+    }
+
+    private func restoreThumbnailsFromCache(_ cachedThumbnails: [UInt32: Any]) {
+        for card in waterfallCardsByWindowID().values {
+            if let thumbnail = cachedThumbnails[card.item.window.id] {
+                card.thumbnailLayer.contents = thumbnail
+            }
         }
     }
 
@@ -2337,7 +2392,7 @@
             item.containerLayer.removeFromSuperlayer()
         }
 
-        appShelfItems = (currentViewModel?.appShelf ?? []).map { item in
+        appShelfItems = (currentViewModel?.appShelf ?? []).enumerated().map { ordinal, item in
             let container = CALayer()
             container.name = item.app.name
 
@@ -2353,6 +2408,18 @@
             icon.contents = appIconProvider.icon(for: item.app)
             icon.cornerRadius = 12
             icon.masksToBounds = false
+
+            let indexLabel = makeTextLayer(
+                string: appShelfIndexText(forOrdinal: ordinal),
+                fontSize: AppShelfMetrics.indexFontSize,
+                weight: .regular,
+                color: appShelfIndexColor,
+                alignment: .center
+            )
+            indexLabel.font = appShelfIndexFont(size: AppShelfMetrics.indexFontSize)
+            indexLabel.fontSize = AppShelfMetrics.indexFontSize
+            indexLabel.truncationMode = .end
+            indexLabel.zPosition = 14
 
             let label = makeTextLayer(
                 string: item.app.name,
@@ -2378,6 +2445,7 @@
             let closeButton = makeCloseButtonLayers()
 
             container.addSublayer(background)
+            container.addSublayer(indexLabel)
             container.addSublayer(icon)
             container.addSublayer(label)
             container.addSublayer(badge)
@@ -2388,6 +2456,7 @@
                 item: item,
                 containerLayer: container,
                 backgroundLayer: background,
+                indexLayer: indexLabel,
                 iconLayer: icon,
                 labelLayer: label,
                 badgeLayer: badge,
@@ -3421,6 +3490,13 @@
             )
             : nil
         item.iconLayer.frame = CGRect(x: iconX, y: iconY, width: visualIconSize, height: visualIconSize)
+        layoutAppShelfIndexLayer(
+            item.indexLayer,
+            iconFrame: item.iconLayer.frame,
+            iconSize: visualIconSize,
+            itemBounds: bounds,
+            highlighted: showsSelectedVisual || isHovered
+        )
         item.labelLayer.contentsScale = backingScaleFactor
         item.labelLayer.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 14)
         item.badgeLayer.string = badgeText
@@ -3446,6 +3522,67 @@
         )
         item.selectedIndicatorLayer.frame = .zero
         item.selectedIndicatorLayer.backgroundColor = NSColor.clear.cgColor
+    }
+
+    private var appShelfIndexColor: NSColor {
+        NSColor.labelColor.withAlphaComponent(0.34)
+    }
+
+    private var appShelfIndexHighlightedColor: NSColor {
+        NSColor.labelColor.withAlphaComponent(0.42)
+    }
+
+    private func appShelfIndexFont(size: CGFloat) -> NSFont {
+        for fontName in ["Bradley Hand ITC", "Noteworthy-Light", "Marker Felt", "Snell Roundhand"] {
+            if let font = NSFont(name: fontName, size: size) {
+                return font
+            }
+        }
+
+        return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
+    }
+
+    private func appShelfIndexText(forOrdinal ordinal: Int) -> String {
+        let symbols = Array("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ")
+        guard ordinal >= 0 else { return "" }
+        if ordinal < symbols.count {
+            return String(symbols[ordinal])
+        }
+
+        let overflowIndex = ordinal - symbols.count
+        let prefix = symbols[10 + (overflowIndex / 26) % 26]
+        let suffix = symbols[10 + overflowIndex % 26]
+        return "\(prefix)\(suffix)"
+    }
+
+    private func layoutAppShelfIndexLayer(
+        _ layer: CATextLayer,
+        iconFrame: CGRect,
+        iconSize: CGFloat,
+        itemBounds: CGRect,
+        highlighted: Bool
+    ) {
+        let fontSize = iconSize <= AppShelfMetrics.minIconSize + 1
+            ? AppShelfMetrics.compressedIndexFontSize
+            : AppShelfMetrics.indexFontSize
+        layer.contentsScale = backingScaleFactor
+        layer.font = appShelfIndexFont(size: fontSize)
+        layer.fontSize = fontSize
+        layer.foregroundColor = (highlighted ? appShelfIndexHighlightedColor : appShelfIndexColor).cgColor
+        layer.opacity = iconSize <= AppShelfMetrics.minIconSize + 1 ? 0.72 : 0.82
+
+        let indexWidth = AppShelfMetrics.indexWidth
+        let indexHeight = AppShelfMetrics.indexHeight
+        let preferredX = iconFrame.minX - indexWidth - 1
+        let clampedX = max(-6, min(itemBounds.width - indexWidth, preferredX))
+        let preferredY = iconFrame.maxY - indexHeight + 4
+        let clampedY = max(0, min(itemBounds.height - indexHeight, preferredY))
+        layer.frame = CGRect(
+            x: clampedX,
+            y: clampedY,
+            width: indexWidth,
+            height: indexHeight
+        )
     }
 
     private func appShelfRows() -> [[AppShelfItemLayers]] {
@@ -5040,6 +5177,11 @@
                 "frame": dictionary(from: item.containerLayer.frame),
                 "visibleFrame": dictionary(from: visibleFrame),
                 "iconFrame": dictionary(from: item.iconLayer.frame),
+                "indexText": item.indexLayer.string ?? "",
+                "indexFrame": dictionary(from: item.indexLayer.frame),
+                "indexVisible": !item.indexLayer.isHidden && item.indexLayer.opacity > 0,
+                "indexFontSize": Double(item.indexLayer.fontSize),
+                "indexColor": colorDictionary(from: item.indexLayer.foregroundColor),
                 "labelFrame": dictionary(from: item.labelLayer.frame),
                 "labelTruncationMode": "middle",
                 "badgeVisible": !item.badgeLayer.isHidden,
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index daff31e..18d84f0 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -280,7 +280,27 @@
 
     func retriggerFromShortcut() {
         DevelopmentDiagnostics.log("quickSwitch.session.retriggerFromShortcut")
-        show()
+        guard isVisible else {
+            show()
+            return
+        }
+
+        guard currentViewModel != nil else {
+            DevelopmentDiagnostics.log("quickSwitch.session.retriggerFromShortcut.noSnapshot", [
+                "action": "fallbackShow"
+            ])
+            show()
+            return
+        }
+
+        let didCycle = view.hoverNextAppGroupIndex()
+        DevelopmentDiagnostics.log("quickSwitch.session.retriggerFromShortcut.hoverApp", [
+            "didCycle": didCycle
+        ])
+
+        if !didCycle {
+            show()
+        }
     }
 
     func setWaterfallViewMode(_ mode: QuickSwitchWaterfallViewMode) {
diff --git a/C3.tools/round1-app-shelf-fixture-qa.sh b/C3.tools/round1-app-shelf-fixture-qa.sh
index e066efc..586ee5f 100755
--- a/C3.tools/round1-app-shelf-fixture-qa.sh
+++ b/C3.tools/round1-app-shelf-fixture-qa.sh
@@ -104,6 +104,13 @@
         print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
         sys.exit(1)
 
+def expected_index_text(index):
+    symbols = list("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ")
+    if index < len(symbols):
+        return symbols[index]
+    overflow = index - len(symbols)
+    return symbols[10 + (overflow // 26) % 26] + symbols[10 + overflow % 26]
+
 root = report.get("rootView", {})
 items = root.get("appShelfItems", [])
 columns = root.get("waterfallColumns", [])
@@ -171,6 +178,12 @@
 require(normal_items, "fixture must include normal app items")
 require(all(item.get("iconFrame", {}).get("width") == expected_icon_size for item in normal_items), "normal app icons must match expected fixture icon size")
 require(all(item.get("labelTruncationMode") == "middle" for item in items), "all App Shelf labels must use middle truncation")
+require(all(item.get("indexVisible") is True for item in items), "all App Shelf items must expose a visible hand-written index marker")
+require(all(item.get("indexText") == expected_index_text(index) for index, item in enumerate(items)), "App Shelf index markers must follow 1234567890ABCDEFG order")
+require(all(item.get("indexFontSize", 0) >= 13 for item in items), "App Shelf index markers must keep readable font size")
+require(all(0 < item.get("indexColor", {}).get("alpha", 0) <= 0.5 for item in items), "App Shelf index markers must stay low-noise with light alpha")
+require(all(item.get("indexFrame", {}).get("width", 0) >= 14 for item in items), "App Shelf index markers must reserve enough width")
+require(all(item.get("indexFrame", {}).get("x", 99) < item.get("iconFrame", {}).get("x", -99) for item in items), "App Shelf index markers must sit beside the icon, not over the icon center")
 
 row_y_values = {round(item.get("frame", {}).get("y", 0), 2) for item in items}
 require(len(row_y_values) == expected_rows, "fixture App Shelf visible rows must match expected rows")
@@ -192,6 +205,7 @@
     "hoveredColumnTitleColor": hovered_title_color,
     "hoveredColumnCountColor": hovered_count_color,
     "blueReference": blue_reference,
+    "firstIndexes": [item.get("indexText") for item in items[:min(12, len(items))]],
     "selectedZ": selected_z,
     "hoverZ": hover_z,
     "scrollable": root.get("appShelfScrollable"),

--
Gitblit v1.9.3