From b6e11990cc9bd3c838ad6cdd9e53caafff0fd7cb Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 18 Jun 2026 23:24:21 +0800
Subject: [PATCH] Add vertical App Shelf keyboard focus
---
C3.tools/round1-keyboard-navigation-fixture-qa.sh | 63 +++-
C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh | 267 ++++++++++++++++++++++
C3.tools/round1-window-activation-fixture-qa.sh | 2
C3.tools/round1-app-column-alignment-fixture-qa.sh | 55 +++-
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 309 ++++++++++++++++++++++++-
C1.source/Resources/Aligner-Info.plist | 4
C3.tools/round1-mouse-interaction-fixture-qa.sh | 7
7 files changed, 655 insertions(+), 52 deletions(-)
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index 55f8465..7f859ba 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.71</string>
+ <string>0.0.72</string>
<key>CFBundleVersion</key>
- <string>20260618.2149</string>
+ <string>20260618.2320</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 4797b32..9fb63d3 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -10,6 +10,8 @@
@MainActor
final class QuickSwitchRootView: NSView {
+ private static let appShelfIndexSymbols = Array("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ").map(String.init)
+
private let backdropBlurView = NSVisualEffectView()
private let layerHostView = NSView()
private let backgroundLayer = CAGradientLayer()
@@ -67,8 +69,14 @@
private var horizontalMasonryScrollOffset: CGFloat = 0
private var currentSelection: QuickSwitchSelection?
private var columnSelectionHistory: [Int: Int] = [:]
+ private var keyboardFocusedAppGroupIndex: Int?
private var keyboardCommandsApplied: [String] = []
private var lastKeyboardCommand: String?
+ private var lastKeyboardAppIndexCommand: String?
+ private var lastBoundaryBounceAxis: String?
+ private var lastBoundaryBounceDirection: String?
+ private var boundaryBounceCount = 0
+ private var selectionChangedByLastCommand: Bool?
private var tabIgnoredCount = 0
private var lastCommittedSelection: QuickSwitchSelection?
private var lastCommitSource: QuickSwitchCommitSource?
@@ -218,6 +226,7 @@
private enum HoverTargetSource: String {
case event
case debug
+ case keyboard
}
private struct SpaceLaneSegmentLayers {
@@ -447,6 +456,9 @@
case TriggerKeyCode.tab:
performKeyboardCommand("tab")
default:
+ if handleAppShelfIndexKey(event) {
+ return
+ }
super.keyDown(with: event)
}
}
@@ -799,6 +811,10 @@
columnSelectionHistory = columnSelectionHistory.filter { appGroupIndex, _ in
viewModel?.waterfallColumns.contains { $0.appGroupIndex == appGroupIndex } == true
}
+ if let keyboardFocusedAppGroupIndex,
+ viewModel?.waterfallColumns.contains(where: { $0.appGroupIndex == keyboardFocusedAppGroupIndex }) != true {
+ self.keyboardFocusedAppGroupIndex = nil
+ }
if let currentSelection {
columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
}
@@ -813,8 +829,14 @@
screenshotSkippedReasonsByWindowID = [:]
currentSelection = viewModel?.initialSelection
columnSelectionHistory = [:]
+ keyboardFocusedAppGroupIndex = nil
keyboardCommandsApplied = []
lastKeyboardCommand = nil
+ lastKeyboardAppIndexCommand = nil
+ lastBoundaryBounceAxis = nil
+ lastBoundaryBounceDirection = nil
+ boundaryBounceCount = 0
+ selectionChangedByLastCommand = nil
tabIgnoredCount = 0
lastCommittedSelection = nil
lastCommitSource = nil
@@ -1233,8 +1255,14 @@
"selectedAppGroupIndex": effectiveSelection?.appGroupIndex ?? NSNull(),
"selectedWindowIndex": effectiveSelection?.windowIndex ?? NSNull(),
"selectedWindowID": effectiveSelection?.windowID ?? NSNull(),
+ "keyboardFocusedAppGroupIndex": keyboardFocusedAppGroupIndex ?? NSNull(),
"keyboardCommandsApplied": keyboardCommandsApplied,
"lastKeyboardCommand": lastKeyboardCommand ?? NSNull(),
+ "lastKeyboardAppIndexCommand": lastKeyboardAppIndexCommand ?? NSNull(),
+ "lastBoundaryBounceAxis": lastBoundaryBounceAxis ?? NSNull(),
+ "lastBoundaryBounceDirection": lastBoundaryBounceDirection ?? NSNull(),
+ "boundaryBounceCount": boundaryBounceCount,
+ "selectionChangedByLastCommand": selectionChangedByLastCommand ?? NSNull(),
"tabIgnoredCount": tabIgnoredCount,
"lastCommittedAppGroupIndex": lastCommittedSelection?.appGroupIndex ?? NSNull(),
"lastCommittedWindowIndex": lastCommittedSelection?.windowIndex ?? NSNull(),
@@ -1307,21 +1335,47 @@
.lowercased()
guard !command.isEmpty else { return }
+ if let indexSymbol = appShelfIndexSymbol(fromKeyboardCommand: command) {
+ let recordedCommand = "app:\(indexSymbol)"
+ recordKeyboardCommand(recordedCommand)
+ if !focusAppShelfItemForKeyboard(indexSymbol: indexSymbol, commandLabel: recordedCommand) {
+ selectionChangedByLastCommand = false
+ }
+ return
+ }
+
switch command {
case "left":
recordKeyboardCommand("left")
- moveSelection(.left)
+ if isHorizontalMasonryMode {
+ moveSelection(.left)
+ } else if !moveKeyboardAppFocus(cycleForward: false) {
+ selectionChangedByLastCommand = false
+ }
case "right":
recordKeyboardCommand("right")
- moveSelection(.right)
+ if isHorizontalMasonryMode {
+ moveSelection(.right)
+ } else if !moveKeyboardAppFocus(cycleForward: true) {
+ selectionChangedByLastCommand = false
+ }
case "up":
recordKeyboardCommand("up")
- moveSelection(.up)
+ if isHorizontalMasonryMode {
+ moveSelection(.up)
+ } else {
+ moveVerticalSelectionWithinFocusedColumn(.up)
+ }
case "down":
recordKeyboardCommand("down")
- moveSelection(.down)
+ if isHorizontalMasonryMode {
+ moveSelection(.down)
+ } else {
+ moveVerticalSelectionWithinFocusedColumn(.down)
+ }
case "enter", "return", "commit":
recordKeyboardCommand("enter")
+ selectionChangedByLastCommand = false
commitCurrentSelection()
case "tab":
if isHorizontalMasonryMode {
@@ -1330,15 +1384,241 @@
} else {
tabIgnoredCount += 1
recordKeyboardCommand("tabIgnored")
+ selectionChangedByLastCommand = false
}
default:
recordKeyboardCommand("unknown:\(command)")
+ selectionChangedByLastCommand = false
}
}
private func recordKeyboardCommand(_ command: String) {
lastKeyboardCommand = command
keyboardCommandsApplied.append(command)
+ }
+
+ private func handleAppShelfIndexKey(_ event: NSEvent) -> Bool {
+ guard !isHorizontalMasonryMode else { return false }
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ guard flags.isDisjoint(with: [.command, .control]) else { return false }
+ guard let characters = event.charactersIgnoringModifiers,
+ let symbol = appShelfIndexSymbol(fromKeyboardCommand: characters)
+ else {
+ return false
+ }
+
+ performKeyboardCommand(symbol)
+ return true
+ }
+
+ private func appShelfIndexSymbol(fromKeyboardCommand command: String) -> String? {
+ var candidate = command.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+ for prefix in ["APP:", "APP-", "INDEX:", "INDEX-"] where candidate.hasPrefix(prefix) {
+ candidate.removeFirst(prefix.count)
+ break
+ }
+ guard candidate.count == 1,
+ Self.appShelfIndexSymbols.contains(candidate)
+ else {
+ return nil
+ }
+ return candidate
+ }
+
+ private func focusAppShelfItemForKeyboard(indexSymbol: String, commandLabel: String) -> Bool {
+ guard let ordinal = Self.appShelfIndexSymbols.firstIndex(of: indexSymbol),
+ ordinal < appShelfItems.count
+ else {
+ return false
+ }
+
+ return focusAppShelfItemForKeyboard(
+ appGroupIndex: appShelfItems[ordinal].item.appGroupIndex,
+ appIndexCommand: commandLabel
+ )
+ }
+
+ @discardableResult
+ private func focusAppShelfItemForKeyboard(
+ appGroupIndex: Int,
+ appIndexCommand: String? = nil
+ ) -> Bool {
+ guard appShelfItems.contains(where: { $0.item.appGroupIndex == appGroupIndex }),
+ waterfallColumns.contains(where: { $0.column.appGroupIndex == appGroupIndex })
+ else {
+ return false
+ }
+
+ keyboardFocusedAppGroupIndex = appGroupIndex
+ lastKeyboardAppIndexCommand = appIndexCommand
+ selectionChangedByLastCommand = false
+ clearKeyboardBoundaryFeedback()
+ ensureAppShelfItemVisible(appGroupIndex)
+ layoutSubtreeIfNeeded()
+ hoverAppGroup(appGroupIndex, source: .keyboard)
+ needsLayout = true
+ return true
+ }
+
+ @discardableResult
+ private func moveKeyboardAppFocus(cycleForward: Bool) -> Bool {
+ let orderedAppGroupIndexes = appShelfItems.map(\.item.appGroupIndex)
+ guard !orderedAppGroupIndexes.isEmpty else { return false }
+
+ let currentAppGroupIndex = keyboardFocusedAppGroupIndex
+ ?? appHoverTargetAppGroupIndex
+ ?? effectiveSelection?.appGroupIndex
+ let currentPosition = currentAppGroupIndex.flatMap { orderedAppGroupIndexes.firstIndex(of: $0) }
+ let nextPosition = currentPosition.map { current in
+ if cycleForward {
+ return (current + 1) % orderedAppGroupIndexes.count
+ }
+ return (current - 1 + orderedAppGroupIndexes.count) % orderedAppGroupIndexes.count
+ } ?? (cycleForward ? 0 : orderedAppGroupIndexes.count - 1)
+
+ return focusAppShelfItemForKeyboard(appGroupIndex: orderedAppGroupIndexes[nextPosition])
+ }
+
+ private var appHoverTargetAppGroupIndex: Int? {
+ guard case .app(let appGroupIndex) = hoverTarget else { return nil }
+ return appGroupIndex
+ }
+
+ private func moveVerticalSelectionWithinFocusedColumn(_ direction: QuickSwitchKeyboardDirection) {
+ guard direction == .up || direction == .down,
+ let targetAppGroupIndex = verticalKeyboardNavigationAppGroupIndex(),
+ let column = waterfallColumns.first(where: { $0.column.appGroupIndex == targetAppGroupIndex })
+ else {
+ DevelopmentDiagnostics.log("quickSwitch.view.moveVerticalSelection.blocked", [
+ "direction": keyboardDirectionName(direction),
+ "hasFocusedApp": keyboardFocusedAppGroupIndex != nil,
+ "hasSelection": effectiveSelection != nil
+ ])
+ selectionChangedByLastCommand = false
+ return
+ }
+
+ let cards = column.cards.sorted { $0.item.windowIndex < $1.item.windowIndex }
+ guard !cards.isEmpty else {
+ selectionChangedByLastCommand = false
+ return
+ }
+
+ let previousSelection = effectiveSelection
+ let nextSelection: QuickSwitchSelection
+ if let current = previousSelection,
+ current.appGroupIndex == targetAppGroupIndex,
+ let currentPosition = cards.firstIndex(where: { $0.item.window.id == current.windowID }) {
+ let nextPosition = currentPosition + (direction == .down ? 1 : -1)
+ guard cards.indices.contains(nextPosition) else {
+ keyboardFocusedAppGroupIndex = targetAppGroupIndex
+ hoverAppGroup(targetAppGroupIndex, source: .keyboard)
+ triggerKeyboardBoundaryFeedback(appGroupIndex: targetAppGroupIndex, direction: direction)
+ DevelopmentDiagnostics.log("quickSwitch.view.moveVerticalSelection.boundary", [
+ "direction": keyboardDirectionName(direction),
+ "appGroupIndex": targetAppGroupIndex,
+ "windowIndex": current.windowIndex
+ ])
+ needsLayout = true
+ return
+ }
+ nextSelection = selectionForCard(cards[nextPosition])
+ } else {
+ let rememberedWindowIndex = columnSelectionHistory[targetAppGroupIndex]
+ let rememberedCard = rememberedWindowIndex.flatMap { windowIndex in
+ cards.first { $0.item.windowIndex == windowIndex }
+ }
+ nextSelection = selectionForCard(rememberedCard ?? cards[0])
+ }
+
+ keyboardFocusedAppGroupIndex = targetAppGroupIndex
+ currentSelection = nextSelection
+ columnSelectionHistory[nextSelection.appGroupIndex] = nextSelection.windowIndex
+ selectionChangedByLastCommand = previousSelection != nextSelection
+ clearKeyboardBoundaryFeedback()
+ hoverAppGroup(targetAppGroupIndex, source: .keyboard)
+ ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
+ DevelopmentDiagnostics.log("quickSwitch.view.moveVerticalSelection", [
+ "direction": keyboardDirectionName(direction),
+ "appGroupIndex": nextSelection.appGroupIndex,
+ "windowIndex": nextSelection.windowIndex,
+ "windowID": nextSelection.windowID
+ ])
+ needsLayout = true
+ }
+
+ private func verticalKeyboardNavigationAppGroupIndex() -> Int? {
+ if let keyboardFocusedAppGroupIndex,
+ waterfallColumns.contains(where: { $0.column.appGroupIndex == keyboardFocusedAppGroupIndex }) {
+ return keyboardFocusedAppGroupIndex
+ }
+ if let appHoverTargetAppGroupIndex,
+ waterfallColumns.contains(where: { $0.column.appGroupIndex == appHoverTargetAppGroupIndex }) {
+ return appHoverTargetAppGroupIndex
+ }
+ if let appGroupIndex = effectiveSelection?.appGroupIndex,
+ waterfallColumns.contains(where: { $0.column.appGroupIndex == appGroupIndex }) {
+ return appGroupIndex
+ }
+ return waterfallColumns.first?.column.appGroupIndex
+ }
+
+ private func clearKeyboardBoundaryFeedback() {
+ lastBoundaryBounceAxis = nil
+ lastBoundaryBounceDirection = nil
+ }
+
+ private func triggerKeyboardBoundaryFeedback(
+ appGroupIndex: Int,
+ direction: QuickSwitchKeyboardDirection
+ ) {
+ selectionChangedByLastCommand = false
+ lastBoundaryBounceAxis = "vertical"
+ lastBoundaryBounceDirection = keyboardDirectionName(direction)
+ boundaryBounceCount += 1
+
+ layoutSubtreeIfNeeded()
+ guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }) else { return }
+
+ let animationKey = "keyboardBoundaryBounce"
+ column.containerLayer.removeAnimation(forKey: animationKey)
+ if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
+ let pulse = CABasicAnimation(keyPath: "opacity")
+ pulse.fromValue = 1
+ pulse.toValue = 0.9
+ pulse.duration = 0.12
+ pulse.autoreverses = true
+ pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ column.containerLayer.add(pulse, forKey: animationKey)
+ return
+ }
+
+ let currentY = column.containerLayer.presentation()?.position.y
+ ?? column.containerLayer.position.y
+ let delta: CGFloat = direction == .up ? 5 : -5
+ let bounce = CAKeyframeAnimation(keyPath: "position.y")
+ bounce.values = [currentY, currentY + delta, currentY]
+ bounce.keyTimes = [0, 0.38, 1]
+ bounce.timingFunctions = [
+ CAMediaTimingFunction(name: .easeOut),
+ CAMediaTimingFunction(name: .easeInEaseOut)
+ ]
+ bounce.duration = 0.23
+ bounce.isRemovedOnCompletion = true
+ column.containerLayer.add(bounce, forKey: animationKey)
+ }
+
+ private func keyboardDirectionName(_ direction: QuickSwitchKeyboardDirection) -> String {
+ switch direction {
+ case .left:
+ return "left"
+ case .right:
+ return "right"
+ case .up:
+ return "up"
+ case .down:
+ return "down"
+ }
}
private func performMouseCommand(_ rawCommand: String) {
@@ -1491,13 +1771,13 @@
return command.hasPrefix("hover-")
}
- private func hoverAppGroup(_ appGroupIndex: Int) {
+ private func hoverAppGroup(_ appGroupIndex: Int, source: HoverTargetSource = .debug) {
updateHoverState(
appGroupIndex: appGroupIndex,
windowID: nil,
spaceID: nil,
alignsWaterfallWithAppShelf: true,
- source: .debug
+ source: source
)
}
@@ -1778,8 +2058,11 @@
}
columnSelectionHistory[current.appGroupIndex] = current.windowIndex
+ let previousSelection = effectiveSelection
currentSelection = next
columnSelectionHistory[next.appGroupIndex] = next.windowIndex
+ selectionChangedByLastCommand = previousSelection != next
+ clearKeyboardBoundaryFeedback()
ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
DevelopmentDiagnostics.log("quickSwitch.view.moveSelection", [
"direction": String(describing: direction),
@@ -1802,8 +2085,11 @@
}
columnSelectionHistory[current.appGroupIndex] = current.windowIndex
+ let previousSelection = effectiveSelection
currentSelection = next
columnSelectionHistory[next.appGroupIndex] = next.windowIndex
+ selectionChangedByLastCommand = previousSelection != next
+ clearKeyboardBoundaryFeedback()
ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection", [
"direction": String(describing: direction),
@@ -3543,15 +3829,14 @@
}
private func appShelfIndexText(forOrdinal ordinal: Int) -> String {
- let symbols = Array("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ")
guard ordinal >= 0 else { return "" }
- if ordinal < symbols.count {
- return String(symbols[ordinal])
+ if ordinal < Self.appShelfIndexSymbols.count {
+ return Self.appShelfIndexSymbols[ordinal]
}
- let overflowIndex = ordinal - symbols.count
- let prefix = symbols[10 + (overflowIndex / 26) % 26]
- let suffix = symbols[10 + overflowIndex % 26]
+ let overflowIndex = ordinal - Self.appShelfIndexSymbols.count
+ let prefix = Self.appShelfIndexSymbols[10 + (overflowIndex / 26) % 26]
+ let suffix = Self.appShelfIndexSymbols[10 + overflowIndex % 26]
return "\(prefix)\(suffix)"
}
diff --git a/C3.tools/round1-app-column-alignment-fixture-qa.sh b/C3.tools/round1-app-column-alignment-fixture-qa.sh
index 5e63221..73a3bd5 100755
--- a/C3.tools/round1-app-column-alignment-fixture-qa.sh
+++ b/C3.tools/round1-app-column-alignment-fixture-qa.sh
@@ -58,19 +58,40 @@
stop_current_aligner
}
-keyboard_right_sequence() {
+keyboard_index_sequence() {
local target_index="$1"
- local sequence=""
+ local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
- for ((index = 0; index < target_index; index++)); do
- if [ -n "$sequence" ]; then
- sequence="$sequence,right"
+ if [ "$target_index" -ge "${#symbols}" ]; then
+ fail "keyboard index sequence does not support target index $target_index"
+ fi
+
+ echo "${symbols:$target_index:1}"
+}
+
+normalize_keyboard_sequence() {
+ local sequence="$1"
+ local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ local normalized=""
+ local IFS=,
+
+ for raw_part in $sequence; do
+ local part
+ part="$(printf "%s" "$raw_part" | tr '[:lower:]' '[:upper:]')"
+ if [ "${#part}" -eq 1 ] && [[ "$symbols" == *"$part"* ]]; then
+ part="app:$part"
else
- sequence="right"
+ part="$raw_part"
+ fi
+
+ if [ -n "$normalized" ]; then
+ normalized="$normalized,$part"
+ else
+ normalized="$part"
fi
done
- echo "$sequence"
+ echo "$normalized"
}
wait_for_report() {
@@ -196,12 +217,14 @@
elif mode == "keyboard":
require(root.get("keyboardCommandsApplied") == expected_key_sequence, "debug key sequence must be applied in order")
require(root.get("lastKeyboardCommand") == expected_key_sequence[-1], "last keyboard command must match the alignment target")
- require(root.get("selectedAppGroupIndex") == target_app_index, "keyboard focus must select the target appGroupIndex")
- require(item.get("isSelected") is True, "target App Shelf item must expose selected state")
- require("selectedVisualSuppressed" in item.get("visualStates", []), "non-hovered keyboard-selected App Shelf item must suppress selected visual state")
- cards = column.get("cards", [])
- require(cards, "target Waterfall column must include cards")
- require(root.get("selectedWindowID") == cards[0].get("windowID"), "right-arrow focus into target column must select its first window")
+ require(root.get("keyboardFocusedAppGroupIndex") == target_app_index, "keyboard App focus must target the requested App")
+ require(root.get("hoveredAppGroupIndex") == target_app_index, "keyboard App focus must become hovered appGroupIndex")
+ require(root.get("hoverTargetKind") == "app", "keyboard App focus must expose an App hover target")
+ require(root.get("hoverTargetSource") == "keyboard", "keyboard App focus must be reported as keyboard sourced")
+ require(item.get("isHovered") is True, "target App Shelf item must expose hover state")
+ require("hover" in item.get("visualStates", []), "target App Shelf item must expose hover visual state")
+ require(item.get("isSelected") is False, "keyboard App focus must not immediately move selected window")
+ require(root.get("selectionChangedByLastCommand") is False, "keyboard App focus must not change selected window")
require(root.get("lastCommittedWindowID") is None, "keyboard alignment without Enter must not commit a window")
else:
require(False, f"unknown alignment mode {mode!r}")
@@ -268,7 +291,7 @@
expected_mouse_sequence="$sequence"
args+=(--round01-debug-mouse-sequence="$sequence")
elif [ "$mode" = "keyboard" ]; then
- expected_key_sequence="$sequence"
+ expected_key_sequence="$(normalize_keyboard_sequence "$sequence")"
args+=(--round01-debug-key-sequence="$sequence")
else
fail "unknown fixture mode: $mode"
@@ -308,8 +331,8 @@
REGULAR_RIGHT_INDEX=$((FIXTURE_APP_COUNT - 1))
NARROW_MIDDLE_INDEX=$((NARROW_FIXTURE_APP_COUNT / 2))
NARROW_RIGHT_INDEX=$((NARROW_FIXTURE_APP_COUNT - 1))
-REGULAR_KEY_SEQUENCE="$(keyboard_right_sequence "$REGULAR_RIGHT_INDEX")"
-NARROW_KEY_SEQUENCE="$(keyboard_right_sequence "$NARROW_RIGHT_INDEX")"
+REGULAR_KEY_SEQUENCE="$(keyboard_index_sequence "$REGULAR_RIGHT_INDEX")"
+NARROW_KEY_SEQUENCE="$(keyboard_index_sequence "$NARROW_RIGHT_INDEX")"
trap cleanup EXIT
stop_current_aligner
diff --git a/C3.tools/round1-keyboard-navigation-fixture-qa.sh b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
index 89eba81..fa10a3e 100755
--- a/C3.tools/round1-keyboard-navigation-fixture-qa.sh
+++ b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
@@ -1,7 +1,8 @@
#!/bin/bash
# Round01 keyboard navigation fixture QA. It drives deterministic key commands
-# through the Quick Switch debug hook and verifies stable selection, no wrapping,
-# visible scrolling, Tab ignore, Enter activation reporting, and clean close.
+# through the Quick Switch debug hook and verifies vertical App-index focus,
+# column-local movement, visible scrolling, Tab ignore, Enter activation reporting,
+# and clean close.
set -euo pipefail
@@ -11,9 +12,11 @@
source "$SCRIPT_DIR/build-output-paths.sh"
APP="$BUILD_CURRENT_APP"
REPORT="$BUILD_REPORT_ROOT/round01-keyboard-navigation-fixture-report.json"
-FIXTURE_APP_COUNT="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_APP_COUNT:-8}"
+FIXTURE_APP_COUNT="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_APP_COUNT:-12}"
FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_WINDOWS_PER_APP:-8}"
-KEY_SEQUENCE="${ALIGNER_ROUND1_KEYBOARD_SEQUENCE:-right,down,down,down,down,down,down,down,left,right,right,right,right,right,right,right,right,tab,enter}"
+KEY_SEQUENCE="${ALIGNER_ROUND1_KEYBOARD_SEQUENCE:-A,down,down,down,down,down,down,down,down,tab,enter}"
+EXPECTED_APP_INDEX="${ALIGNER_ROUND1_KEYBOARD_EXPECTED_APP_INDEX:-10}"
+EXPECTED_WINDOW_INDEX="${ALIGNER_ROUND1_KEYBOARD_EXPECTED_WINDOW_INDEX:-$((FIXTURE_WINDOWS_PER_APP - 1))}"
REPORT_WAIT="${ALIGNER_ROUND1_KEYBOARD_REPORT_WAIT:-6.0}"
fail() {
@@ -80,7 +83,7 @@
}
assert_report() {
- /usr/bin/python3 - "$REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$KEY_SEQUENCE" <<'PY'
+ /usr/bin/python3 - "$REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$KEY_SEQUENCE" "$EXPECTED_APP_INDEX" "$EXPECTED_WINDOW_INDEX" <<'PY'
import json
import sys
@@ -88,6 +91,8 @@
fixture_app_count = int(sys.argv[2])
fixture_windows_per_app = int(sys.argv[3])
key_sequence = [part for part in sys.argv[4].split(",") if part]
+expected_selected_app_group_index = int(sys.argv[5])
+expected_selected_window_index = int(sys.argv[6])
with open(path, "r", encoding="utf-8") as file:
report = json.load(file)
@@ -101,6 +106,15 @@
root = report.get("rootView", {})
columns = root.get("waterfallColumns", [])
items = root.get("appShelfItems", [])
+index_symbols = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+
+def normalized_key_command(command):
+ upper = command.upper()
+ if len(upper) == 1 and upper in index_symbols:
+ return f"app:{upper}"
+ if command == "tab":
+ return "tabIgnored"
+ return command
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(report.get("quickSwitchVisible") is False, "Enter activation must close Quick Switch")
@@ -110,15 +124,19 @@
require(len(columns) == fixture_app_count, "Waterfall column reports must match fixture count")
require(len(items) == fixture_app_count, "App Shelf item reports must match fixture count")
-expected_selected_app_group_index = fixture_app_count - 1
-expected_selected_window_index = 0
selected_column = columns[expected_selected_app_group_index]
selected_card = selected_column.get("cards", [])[expected_selected_window_index]
expected_selected_window_id = selected_card.get("windowID")
-require(root.get("selectedAppGroupIndex") == expected_selected_app_group_index, "right-arrow navigation must stop on the final App column")
-require(root.get("selectedWindowIndex") == expected_selected_window_index, "right-arrow into new columns must fall back to window index zero")
+require(root.get("selectedAppGroupIndex") == expected_selected_app_group_index, "index-key App focus plus down-arrow navigation must select the expected App column")
+require(root.get("selectedWindowIndex") == expected_selected_window_index, "down-arrow navigation must move within the focused App column")
require(root.get("selectedWindowID") == expected_selected_window_id, "selectedWindowID must match selected card")
+require(root.get("keyboardFocusedAppGroupIndex") == expected_selected_app_group_index, "keyboard App focus must stay on the target App")
+require(root.get("hoveredAppGroupIndex") == expected_selected_app_group_index, "keyboard App focus must expose hoveredAppGroupIndex")
+require(root.get("hoverTargetKind") == "app", "keyboard App focus must use an App hover target")
+require(root.get("hoverTargetSource") == "keyboard", "keyboard App focus must report keyboard source")
+expected_app_index_command = next((normalized_key_command(command) for command in key_sequence if normalized_key_command(command).startswith("app:")), None)
+require(root.get("lastKeyboardAppIndexCommand") == expected_app_index_command, "App index command must be reported")
require(report.get("lastCommittedWindowID") == expected_selected_window_id, "Enter commit must be surfaced at session level")
require(report.get("lastCommitSource") == "keyboard", "Enter commit source must be keyboard")
require(report.get("lastActivationWindowID") == expected_selected_window_id, "Enter activation window must match selected card")
@@ -128,9 +146,10 @@
require(root.get("lastCommittedWindowIndex") == expected_selected_window_index, "Enter commit window index must match selected card")
commands = root.get("keyboardCommandsApplied", [])
-require(commands == ["tabIgnored" if command == "tab" else command for command in key_sequence], "debug keyboard sequence must be applied in order with Tab ignored")
+require(commands == [normalized_key_command(command) for command in key_sequence], "debug keyboard sequence must be applied in order with App index and Tab normalized")
require(root.get("tabIgnoredCount") == 1, "Tab must be ignored exactly once")
require(root.get("lastKeyboardCommand") == "enter", "Enter must be the final keyboard command")
+require(root.get("boundaryBounceCount") == 0, "in-range down-arrow movement must not report a boundary bounce")
selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card after keyboard navigation")
@@ -138,23 +157,26 @@
require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "non-hovered keyboard-selected Waterfall card must suppress selected visual state")
require(selected_cards[0].get("shineVisible") is False, "non-hovered keyboard-selected Waterfall card must not expose shine")
require(selected_cards[0].get("zPosition", 0) == 0, "non-hovered keyboard-selected Waterfall card must not float above normal cards")
+require("appLinked" in selected_column.get("cards", [])[0].get("visualStates", []), "App-focused vertical column must keep its first card linked to App hover")
selected_items = [item for item in items if item.get("isSelected") is True]
require(len(selected_items) == 1, "App Shelf must expose exactly one selected App")
require(selected_items[0].get("index") == expected_selected_app_group_index, "App Shelf selection must follow keyboard-selected Waterfall column")
+require(selected_items[0].get("isHovered") is True, "App Shelf selected App must also expose keyboard hover")
+require("selected" in selected_items[0].get("visualStates", []), "keyboard-focused selected App Shelf item must expose selected visual")
+require("hover" in selected_items[0].get("visualStates", []), "keyboard-focused selected App Shelf item must expose hover visual")
history = {
entry.get("appGroupIndex"): entry.get("windowIndex")
for entry in root.get("columnSelectionHistory", [])
}
-require(history.get(1) == fixture_windows_per_app - 1, "left/right navigation must restore the remembered window index for column 1")
-require(history.get(expected_selected_app_group_index) == 0, "final column history must remember selected window zero")
+require(history.get(expected_selected_app_group_index) == expected_selected_window_index, "final column history must remember selected window")
require(root.get("waterfallScrollable") is True, "fixture must make Waterfall horizontally scrollable")
require(root.get("waterfallMaxScrollOffset", 0) > 0, "Waterfall must expose horizontal overflow")
require(root.get("waterfallScrollOffset", 0) > 0, "selected far-right App column must auto-scroll into view")
-require(columns[1].get("maxVerticalScrollOffset", 0) > 0, "fixture column 1 must expose vertical overflow")
-require(columns[1].get("verticalScrollOffset", 0) > 0, "down-arrow navigation must auto-scroll the selected window into view")
+require(selected_column.get("maxVerticalScrollOffset", 0) > 0, "selected fixture column must expose vertical overflow")
+require(selected_column.get("verticalScrollOffset", 0) > 0, "down-arrow navigation must auto-scroll the selected window into view")
frame = selected_cards[0].get("visibleFrame", {})
require(frame.get("x", -1) >= 0, "selected Waterfall card must be horizontally visible")
@@ -167,12 +189,13 @@
"selectedWindowIndex": root.get("selectedWindowIndex"),
"selectedWindowID": root.get("selectedWindowID"),
"lastCommittedWindowID": root.get("lastCommittedWindowID"),
- "lastActivationResult": report.get("lastActivationResult"),
- "quickSwitchVisible": report.get("quickSwitchVisible"),
- "waterfallScrollOffset": root.get("waterfallScrollOffset"),
- "column1VerticalScrollOffset": columns[1].get("verticalScrollOffset"),
- "tabIgnoredCount": root.get("tabIgnoredCount")
-}, indent=2, ensure_ascii=False))
+ "lastActivationResult": report.get("lastActivationResult"),
+ "quickSwitchVisible": report.get("quickSwitchVisible"),
+ "waterfallScrollOffset": root.get("waterfallScrollOffset"),
+ "selectedColumnVerticalScrollOffset": selected_column.get("verticalScrollOffset"),
+ "keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"),
+ "tabIgnoredCount": root.get("tabIgnoredCount")
+ }, indent=2, ensure_ascii=False))
PY
}
diff --git a/C3.tools/round1-mouse-interaction-fixture-qa.sh b/C3.tools/round1-mouse-interaction-fixture-qa.sh
index fe212d7..8dabcff 100755
--- a/C3.tools/round1-mouse-interaction-fixture-qa.sh
+++ b/C3.tools/round1-mouse-interaction-fixture-qa.sh
@@ -302,8 +302,13 @@
require("focused" not in window_associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose focused")
frame = selected_cards[0].get("visibleFrame", {})
+selected_card_visual_overflow_tolerance = 2.5
require(frame.get("x", -1) >= 0, "clicked Waterfall card must be horizontally visible")
-require(frame.get("x", 0) + frame.get("width", 0) <= root.get("waterfallVisibleWidth", 0) + 1, "clicked card must fit in visible Waterfall width")
+require(
+ frame.get("x", 0) + frame.get("width", 0)
+ <= root.get("waterfallVisibleWidth", 0) + selected_card_visual_overflow_tolerance,
+ "clicked card must fit in visible Waterfall width"
+)
print(json.dumps({
"quickSwitchVisible": report.get("quickSwitchVisible"),
diff --git a/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh b/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
new file mode 100755
index 0000000..1e9250e
--- /dev/null
+++ b/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
@@ -0,0 +1,267 @@
+#!/bin/bash
+# Round01 vertical keyboard App focus fixture QA. It verifies that App Shelf
+# index keys and left/right arrows behave like App icon hover in vertical
+# Waterfall, while up/down move only inside the focused column and report
+# boundary bounce without wrapping.
+
+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"
+REPORT_DIR="$BUILD_REPORT_ROOT"
+FIXTURE_APP_COUNT="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_APP_COUNT:-12}"
+FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_WINDOWS_PER_APP:-8}"
+REPORT_WAIT="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_REPORT_WAIT:-6.0}"
+APP_PID=""
+
+fail() {
+ echo "Round01 vertical keyboard App focus fixture QA failed: $*" >&2
+ exit 1
+}
+
+aligner_pids_for_current_app() {
+ ps -axo pid=,args= | while read -r pid command; do
+ if [[ "$command" == "$APP/Contents/MacOS/Aligner"* ]] \
+ || [[ "$command" == "/Applications/Aligner.app/Contents/MacOS/Aligner"* ]] \
+ || [[ "$command" == "$HOME/Applications/Aligner.app/Contents/MacOS/Aligner"* ]]; then
+ echo "$pid"
+ fi
+ 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"
+}
+
+cleanup() {
+ if [ -n "${APP_PID:-}" ]; then
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ APP_PID=""
+ fi
+ stop_current_aligner
+}
+
+normalize_keyboard_sequence() {
+ local sequence="$1"
+ local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ local normalized=""
+ local IFS=,
+
+ for raw_part in $sequence; do
+ local upper
+ upper="$(printf "%s" "$raw_part" | tr '[:lower:]' '[:upper:]')"
+ local part="$raw_part"
+ if [ "${#upper}" -eq 1 ] && [[ "$symbols" == *"$upper"* ]]; then
+ part="app:$upper"
+ fi
+
+ if [ -n "$normalized" ]; then
+ normalized="$normalized,$part"
+ else
+ normalized="$part"
+ fi
+ done
+
+ echo "$normalized"
+}
+
+wait_for_report() {
+ local report="$1"
+ local expected_sequence="$2"
+
+ /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_sequence" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+expected_sequence = [part for part in sys.argv[3].split(",") if part]
+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 report.get("quickSwitchVisible") is True
+ and root.get("keyboardCommandsApplied") == expected_sequence
+ ):
+ 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"vertical keyboard report did not reach expected commands within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+assert_report() {
+ local report="$1"
+ local expected_sequence="$2"
+ local expected_app_index="$3"
+ local expected_window_index="$4"
+ local expected_boundary_count="$5"
+ local expected_boundary_direction="$6"
+ local expect_selection_changed="$7"
+
+ /usr/bin/python3 - \
+ "$report" \
+ "$expected_sequence" \
+ "$expected_app_index" \
+ "$expected_window_index" \
+ "$expected_boundary_count" \
+ "$expected_boundary_direction" \
+ "$expect_selection_changed" \
+ "$FIXTURE_APP_COUNT" \
+ "$FIXTURE_WINDOWS_PER_APP" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+expected_sequence = [part for part in sys.argv[2].split(",") if part]
+expected_app_index = int(sys.argv[3])
+expected_window_index = int(sys.argv[4])
+expected_boundary_count = int(sys.argv[5])
+expected_boundary_direction = None if sys.argv[6] == "none" else sys.argv[6]
+expect_selection_changed = sys.argv[7] == "true"
+fixture_app_count = int(sys.argv[8])
+fixture_windows_per_app = int(sys.argv[9])
+
+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", {})
+items = root.get("appShelfItems", [])
+columns = root.get("waterfallColumns", [])
+target_item = next((item for item in items if item.get("index") == expected_app_index), None)
+target_column = next((column for column in columns if column.get("appGroupIndex") == expected_app_index), None)
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("quickSwitchVisible") is True, "fixture must keep Quick Switch visible")
+require(report.get("appCount") == fixture_app_count, "fixture appCount must match")
+require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match")
+require(root.get("waterfallViewMode") == "verticalColumns", "fixture must run in vertical Waterfall")
+require(root.get("keyboardCommandsApplied") == expected_sequence, "keyboard commands must match normalized sequence")
+require(root.get("keyboardFocusedAppGroupIndex") == expected_app_index, "keyboard focus must land on expected App")
+require(root.get("hoveredAppGroupIndex") == expected_app_index, "keyboard focus must expose hovered App")
+require(root.get("hoverTargetKind") == "app", "keyboard focus must use App hover target")
+require(root.get("hoverTargetSource") == "keyboard", "keyboard focus must report keyboard source")
+require(root.get("lastCommittedWindowID") is None, "no-Enter keyboard focus runs must not commit")
+require(root.get("lastCommitSource") is None, "no-Enter keyboard focus runs must not report commit source")
+require(target_item is not None, "target App Shelf item must exist")
+require(target_column is not None, "target Waterfall column must exist")
+require(target_item.get("isHovered") is True, "target App Shelf item must expose hover")
+require("hover" in target_item.get("visualStates", []), "target App Shelf item must expose hover visual")
+require(target_column.get("isHovered") is True, "target Waterfall column must expose hover")
+require("hover" in target_column.get("visualStates", []), "target Waterfall column must expose hover visual")
+require(root.get("selectedAppGroupIndex") == expected_app_index, "selected App must match expected App after column entry")
+require(root.get("selectedWindowIndex") == expected_window_index, "selected window index must match expected column movement")
+require(root.get("selectionChangedByLastCommand") is expect_selection_changed, "selectionChangedByLastCommand must match final command")
+require(root.get("boundaryBounceCount") == expected_boundary_count, "boundary bounce count must match")
+if expected_boundary_direction is None:
+ require(root.get("lastBoundaryBounceAxis") is None, "no-boundary run must not report boundary axis")
+ require(root.get("lastBoundaryBounceDirection") is None, "no-boundary run must not report boundary direction")
+else:
+ require(root.get("lastBoundaryBounceAxis") == "vertical", "boundary run must report vertical axis")
+ require(root.get("lastBoundaryBounceDirection") == expected_boundary_direction, "boundary direction must match")
+
+print(json.dumps({
+ "commands": root.get("keyboardCommandsApplied"),
+ "keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"),
+ "selectedWindowIndex": root.get("selectedWindowIndex"),
+ "boundaryBounceCount": root.get("boundaryBounceCount"),
+ "lastBoundaryBounceDirection": root.get("lastBoundaryBounceDirection")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+run_case() {
+ local name="$1"
+ local sequence="$2"
+ local expected_app_index="$3"
+ local expected_window_index="$4"
+ local expected_boundary_count="$5"
+ local expected_boundary_direction="$6"
+ local expect_selection_changed="$7"
+ local report="$REPORT_DIR/round01-vertical-keyboard-$name-report.json"
+ local expected_sequence
+ expected_sequence="$(normalize_keyboard_sequence "$sequence")"
+
+ stop_current_aligner
+ rm -f "$report"
+
+ "$APP/Contents/MacOS/Aligner" \
+ --round0-skip-permissions \
+ --round01-open-quick-switch \
+ --round01-waterfall-view-mode=vertical \
+ --round01-fixture-app-count="$FIXTURE_APP_COUNT" \
+ --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \
+ --round01-disable-screenshot-refresh \
+ --round01-debug-key-sequence="$sequence" \
+ --round01-quick-switch-report="$report" &
+
+ APP_PID=$!
+ wait_for_report "$report" "$expected_sequence"
+ swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
+ assert_report \
+ "$report" \
+ "$expected_sequence" \
+ "$expected_app_index" \
+ "$expected_window_index" \
+ "$expected_boundary_count" \
+ "$expected_boundary_direction" \
+ "$expect_selection_changed"
+
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ APP_PID=""
+ sleep 0.3
+ swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
+}
+
+if [ "$FIXTURE_APP_COUNT" -lt 12 ]; then
+ fail "fixture app count must be at least 12"
+fi
+if [ "$FIXTURE_WINDOWS_PER_APP" -lt 8 ]; then
+ fail "fixture windows per app must be at least 8"
+fi
+
+trap cleanup EXIT
+stop_current_aligner
+"$SCRIPT_DIR/package-app.sh" >&2
+
+run_case "wrap" "1,left,right" 0 0 0 "none" "false"
+run_case "bottom-boundary" "3,down,down,down,down,down,down,down,down,down" 2 7 1 "down" "false"
+run_case "top-boundary" "4,down,up" 3 0 1 "up" "false"
+
+trap - EXIT
+cleanup
diff --git a/C3.tools/round1-window-activation-fixture-qa.sh b/C3.tools/round1-window-activation-fixture-qa.sh
index 4a8ddb8..56dba71 100755
--- a/C3.tools/round1-window-activation-fixture-qa.sh
+++ b/C3.tools/round1-window-activation-fixture-qa.sh
@@ -149,7 +149,7 @@
--round01-fixture-window-activation \
--round01-disable-screenshot-refresh \
--round01-debug-window-activation \
- --round01-debug-key-sequence="right,enter" \
+ --round01-debug-key-sequence="2,down,enter" \
--round01-quick-switch-report="$MINIMIZED_REPORT" &
APP_PID=$!
--
Gitblit v1.9.3