Ariver
2026-06-19 81b5a5f847e74fe1106829b13db2cccda8dd5798
Fix horizontal app index key handling
3 files modified
181 ■■■■■ changed files
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchRootView.swift 56 ●●●●● patch | view | raw | blame | history
C3.tools/round1-horizontal-waterfall-fixture-qa.sh 121 ●●●●● 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.75</string>
    <string>0.0.76</string>
    <key>CFBundleVersion</key>
    <string>20260619.1919</string>
    <string>20260619.1947</string>
    <key>LSMinimumSystemVersion</key>
    <string>26.0</string>
    <key>NSHighResolutionCapable</key>
C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -11,6 +11,15 @@
@MainActor
final class QuickSwitchRootView: NSView {
    private static let appShelfIndexSymbols = Array("1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ").map(String.init)
    private static let appShelfIndexKeyCodes: [String: UInt16] = [
        "1": 18, "2": 19, "3": 20, "4": 21, "5": 23,
        "6": 22, "7": 26, "8": 28, "9": 25, "0": 29,
        "A": 0, "B": 11, "C": 8, "D": 2, "E": 14, "F": 3,
        "G": 5, "H": 4, "I": 34, "J": 38, "K": 40, "L": 37,
        "M": 46, "N": 45, "O": 31, "P": 35, "Q": 12, "R": 15,
        "S": 1, "T": 17, "U": 32, "V": 9, "W": 13, "X": 7,
        "Y": 16, "Z": 6
    ]
    private let backdropBlurView = NSVisualEffectView()
    private let layerHostView = NSView()
@@ -74,6 +83,8 @@
    private var keyboardCommandsApplied: [String] = []
    private var lastKeyboardCommand: String?
    private var lastKeyboardAppIndexCommand: String?
    private var appShelfIndexKeyDownCount = 0
    private var lastAppShelfIndexKeySymbol: String?
    private var lastBoundaryBounceAxis: String?
    private var lastBoundaryBounceDirection: String?
    private var boundaryBounceCount = 0
@@ -644,11 +655,49 @@
        layoutSubtreeIfNeeded()
        for command in commands {
            let measurementStart = CACurrentMediaTime()
            performKeyboardCommand(command)
            if !performDebugPhysicalIndexKeyCommand(command) {
                performKeyboardCommand(command)
            }
            layoutSubtreeIfNeeded()
            CATransaction.flush()
            keyboardResponseLatencyMilliseconds.append(milliseconds(since: measurementStart))
        }
    }
    private func performDebugPhysicalIndexKeyCommand(_ rawCommand: String) -> Bool {
        let command = rawCommand.trimmingCharacters(in: .whitespacesAndNewlines)
        let uppercased = command.uppercased()
        let prefixes = ["PHYSICAL-INDEX:", "PHYSICAL-INDEX-", "KEYDOWN:", "KEYDOWN-"]
        guard let prefix = prefixes.first(where: { uppercased.hasPrefix($0) }) else {
            return false
        }
        let symbolStart = command.index(command.startIndex, offsetBy: prefix.count)
        guard let symbol = appShelfIndexSymbol(fromKeyboardCommand: String(command[symbolStart...])),
              let keyCode = Self.appShelfIndexKeyCodes[symbol],
              let event = NSEvent.keyEvent(
                with: .keyDown,
                location: .zero,
                modifierFlags: [.option],
                timestamp: ProcessInfo.processInfo.systemUptime,
                windowNumber: window?.windowNumber ?? 0,
                context: nil,
                characters: symbol.lowercased(),
                charactersIgnoringModifiers: symbol.lowercased(),
                isARepeat: false,
                keyCode: keyCode
              )
        else {
            recordKeyboardCommand("invalidPhysicalIndex:\(command)")
            selectionChangedByLastCommand = false
            return true
        }
        let previousSuppressEventInput = suppressEventInput
        suppressEventInput = false
        keyDown(with: event)
        suppressEventInput = previousSuppressEventInput
        return true
    }
    func performDebugMouseCommands(_ commands: [String]) {
@@ -1299,6 +1348,8 @@
            "keyboardCommandsApplied": keyboardCommandsApplied,
            "lastKeyboardCommand": lastKeyboardCommand ?? NSNull(),
            "lastKeyboardAppIndexCommand": lastKeyboardAppIndexCommand ?? NSNull(),
            "appShelfIndexKeyDownCount": appShelfIndexKeyDownCount,
            "lastAppShelfIndexKeySymbol": lastAppShelfIndexKeySymbol ?? NSNull(),
            "lastBoundaryBounceAxis": lastBoundaryBounceAxis ?? NSNull(),
            "lastBoundaryBounceDirection": lastBoundaryBounceDirection ?? NSNull(),
            "boundaryBounceCount": boundaryBounceCount,
@@ -1438,7 +1489,6 @@
    }
    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,
@@ -1447,6 +1497,8 @@
            return false
        }
        appShelfIndexKeyDownCount += 1
        lastAppShelfIndexKeySymbol = symbol
        performKeyboardCommand(symbol)
        return true
    }
C3.tools/round1-horizontal-waterfall-fixture-qa.sh
@@ -14,14 +14,18 @@
LAYOUT_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-layout-report.json"
KEYBOARD_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-keyboard-report.json"
APP_ENTER_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-app-enter-report.json"
LETTER_INDEX_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-letter-index-report.json"
ARROW_WRAP_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-arrow-wrap-report.json"
FILTER_REPORT="$BUILD_REPORT_ROOT/round01-horizontal-waterfall-filter-report.json"
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_APP_COUNT:-8}"
FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_FIXTURE_WINDOWS_PER_APP:-6}"
HOVER_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_HOVER_APP_INDEX:-7}"
KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_KEY_SEQUENCE:-tab}"
APP_ENTER_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_APP_ENTER_KEY_SEQUENCE:-app:3,enter}"
APP_ENTER_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_APP_ENTER_KEY_SEQUENCE:-physical-index:3,enter}"
APP_ENTER_TARGET_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_APP_ENTER_TARGET_APP_INDEX:-2}"
LETTER_INDEX_FIXTURE_APP_COUNT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_LETTER_INDEX_FIXTURE_APP_COUNT:-12}"
LETTER_INDEX_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_LETTER_INDEX_KEY_SEQUENCE:-physical-index:A}"
LETTER_INDEX_TARGET_APP_INDEX="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_LETTER_INDEX_TARGET_APP_INDEX:-10}"
ARROW_WRAP_KEY_SEQUENCE="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_ARROW_WRAP_KEY_SEQUENCE:-app:1,down,up,left,down,enter}"
REPORT_WAIT="${ALIGNER_ROUND1_HORIZONTAL_WATERFALL_REPORT_WAIT:-6.0}"
@@ -267,11 +271,31 @@
import sys
path = sys.argv[1]
key_sequence = [part for part in sys.argv[2].split(",") if part]
raw_key_sequence = [part for part in sys.argv[2].split(",") if part]
target_app_index = int(sys.argv[3])
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
def normalize_key_sequence(sequence):
    normalized = []
    physical_symbols = []
    for part in sequence:
        lowered = part.lower()
        matched_prefix = None
        for prefix in ("physical-index:", "physical-index-", "keydown:", "keydown-"):
            if lowered.startswith(prefix):
                matched_prefix = prefix
                break
        if matched_prefix is None:
            normalized.append(part)
            continue
        symbol = part[len(matched_prefix):].strip().upper()
        physical_symbols.append(symbol)
        normalized.append(f"app:{symbol}")
    return normalized, physical_symbols
key_sequence, physical_symbols = normalize_key_sequence(raw_key_sequence)
def require(condition, message):
    if not condition:
@@ -284,6 +308,9 @@
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(root.get("waterfallViewMode") == "horizontalMasonry", "App Enter run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == key_sequence, "App Enter keyboard commands must be applied")
require(root.get("appShelfIndexKeyDownCount") == len(physical_symbols), "physical App index keyDown path must be exercised")
if physical_symbols:
    require(root.get("lastAppShelfIndexKeySymbol") == physical_symbols[-1], "last physical App index symbol must be reported")
require(target_app_index < len(columns), "target App index must exist")
target_card = columns[target_app_index].get("cards", [])[0]
require(root.get("hoverTargetKind") == "app", "App index focus must leave hover target on the App icon")
@@ -305,6 +332,76 @@
    "hoverTargetKind": root.get("hoverTargetKind"),
    "targetAppGroupIndex": target_app_index,
    "lastCommittedWindowID": root.get("lastCommittedWindowID")
}, indent=2, ensure_ascii=False))
PY
}
assert_letter_index_report() {
  /usr/bin/python3 - "$LETTER_INDEX_REPORT" "$LETTER_INDEX_KEY_SEQUENCE" "$LETTER_INDEX_TARGET_APP_INDEX" <<'PY'
import json
import sys
path = sys.argv[1]
raw_key_sequence = [part for part in sys.argv[2].split(",") if part]
target_app_index = int(sys.argv[3])
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)
def normalize_key_sequence(sequence):
    normalized = []
    physical_symbols = []
    for part in sequence:
        lowered = part.lower()
        matched_prefix = None
        for prefix in ("physical-index:", "physical-index-", "keydown:", "keydown-"):
            if lowered.startswith(prefix):
                matched_prefix = prefix
                break
        if matched_prefix is None:
            normalized.append(part)
            continue
        symbol = part[len(matched_prefix):].strip().upper()
        physical_symbols.append(symbol)
        normalized.append(f"app:{symbol}")
    return normalized, physical_symbols
key_sequence, physical_symbols = normalize_key_sequence(raw_key_sequence)
root = report.get("rootView", {})
columns = root.get("waterfallColumns", [])
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(root.get("waterfallViewMode") == "horizontalMasonry", "letter App index run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == key_sequence, "letter App index keyboard commands must be applied")
require(root.get("appShelfIndexKeyDownCount") == len(physical_symbols), "letter App index must exercise physical keyDown path")
require(physical_symbols, "letter App index test must contain a physical key")
require(root.get("lastAppShelfIndexKeySymbol") == physical_symbols[-1], "last letter App index symbol must be reported")
require(target_app_index < len(columns), "target letter App index must exist")
target_card = columns[target_app_index].get("cards", [])[0]
require(root.get("hoverTargetKind") == "app", "letter App index focus must leave hover target on App icon")
require(root.get("hoverTargetSource") == "keyboard", "letter App index focus must report keyboard source")
require(root.get("keyboardFocusedAppGroupIndex") == target_app_index, "letter App index must focus the expected App")
require(root.get("keyboardFocusedWindowID") is None, "letter App icon focus must not keep stale window keyboard focus")
require(root.get("selectedAppGroupIndex") == target_app_index, "letter App icon focus must select the target App")
require(root.get("selectedWindowIndex") == 0, "letter App icon focus must select the first window")
require(root.get("horizontalAppDefaultWindowID") == target_card.get("windowID"), "letter App default window must match the first card")
require(root.get("lastCommittedWindowID") is None, "letter App index focus without Enter must not commit")
require(root.get("lastCommitSource") is None, "letter App index focus without Enter must not report commit source")
require(target_card.get("isHorizontalAppDefault") is True, "letter target first card must report app default state")
require("appDefault" in target_card.get("visualStates", []), "letter target first card must expose appDefault visual state")
require("selected" in target_card.get("visualStates", []), "letter target first card must expose selected visual state")
print(json.dumps({
    "commands": root.get("keyboardCommandsApplied"),
    "physicalKeyDownCount": root.get("appShelfIndexKeyDownCount"),
    "lastSymbol": root.get("lastAppShelfIndexKeySymbol"),
    "targetAppGroupIndex": target_app_index,
    "defaultWindowID": root.get("horizontalAppDefaultWindowID")
}, indent=2, ensure_ascii=False))
PY
}
@@ -406,11 +503,19 @@
  local report="$1"
  shift
  run_fixture_with_app_count "$report" "$FIXTURE_APP_COUNT" "$@"
}
run_fixture_with_app_count() {
  local report="$1"
  local app_count="$2"
  shift 2
  rm -f "$report"
  "$APP/Contents/MacOS/Aligner" \
    --round0-skip-permissions \
    --round01-open-quick-switch \
    --round01-fixture-app-count="$FIXTURE_APP_COUNT" \
    --round01-fixture-app-count="$app_count" \
    --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \
    --round01-disable-screenshot-refresh \
    --round01-waterfall-view-mode=horizontal \
@@ -438,6 +543,9 @@
if [ "$FIXTURE_APP_COUNT" -le "$HOVER_APP_INDEX" ]; then
  fail "hover App index must be inside fixture app count"
fi
if [ "$LETTER_INDEX_FIXTURE_APP_COUNT" -le "$LETTER_INDEX_TARGET_APP_INDEX" ]; then
  fail "letter index target App must be inside letter fixture app count"
fi
if [ "$FIXTURE_WINDOWS_PER_APP" -lt 2 ]; then
  fail "fixture must include at least two windows per App for Tab navigation"
@@ -477,6 +585,13 @@
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
run_fixture_with_app_count "$LETTER_INDEX_REPORT" "$LETTER_INDEX_FIXTURE_APP_COUNT" --round01-debug-key-sequence="$LETTER_INDEX_KEY_SEQUENCE"
wait_for_loaded_report "$LETTER_INDEX_REPORT"
assert_letter_index_report
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
run_fixture "$ARROW_WRAP_REPORT" --round01-debug-key-sequence="$ARROW_WRAP_KEY_SEQUENCE"
wait_for_loaded_report "$ARROW_WRAP_REPORT"
assert_arrow_wrap_report