| C1.source/Resources/Aligner-Info.plist | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/Aligner/QuickSwitchRootView.swift | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-app-column-alignment-fixture-qa.sh | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-keyboard-navigation-fixture-qa.sh | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-mouse-interaction-fixture-qa.sh | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-window-activation-fixture-qa.sh | ●●●●● 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.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> 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)" } 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 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 } 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"), C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
New file @@ -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 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=$!