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