From cb67d8b2fa9b9c9d30cd81d72b8737ee2baa44b6 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 19 Jun 2026 14:09:49 +0800
Subject: [PATCH] Fix vertical keyboard window focus
---
C3.tools/round1-keyboard-navigation-fixture-qa.sh | 20 ++++-
C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh | 111 +++++++++++++++++++--------
C3.tools/round1-main-ui-qa.sh | 1
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 94 +++++++++++++++++++----
C1.source/Resources/Aligner-Info.plist | 4
5 files changed, 174 insertions(+), 56 deletions(-)
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index 7f859ba..a097353 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.72</string>
+ <string>0.0.73</string>
<key>CFBundleVersion</key>
- <string>20260618.2320</string>
+ <string>20260619.1408</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 9fb63d3..f7cbfa2 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -70,6 +70,7 @@
private var currentSelection: QuickSwitchSelection?
private var columnSelectionHistory: [Int: Int] = [:]
private var keyboardFocusedAppGroupIndex: Int?
+ private var keyboardFocusedWindowID: UInt32?
private var keyboardCommandsApplied: [String] = []
private var lastKeyboardCommand: String?
private var lastKeyboardAppIndexCommand: String?
@@ -266,6 +267,7 @@
private struct WaterfallColumnLayers {
let column: QuickSwitchWaterfallColumnViewModel
let containerLayer: CALayer
+ let cardsClipLayer: CALayer
let headerLayer: CALayer
let appNameLayer: CATextLayer
let countLayer: CATextLayer
@@ -355,6 +357,8 @@
static let cardGap: CGFloat = 12
static let revealPadding: CGFloat = 24
static let horizontalRevealPadding: CGFloat = 0
+ static let selectedCardScale: CGFloat = 1.015
+ static let selectedCardScaleOverflow: CGFloat = cardHeight * (selectedCardScale - 1) / 2
}
private enum HorizontalMasonryMetrics {
@@ -739,7 +743,8 @@
if case .realScreenshot = resolution.source {
let isSelected = card.item.window.id == effectiveSelection?.windowID
let isHovered = card.item.window.id == hoveredWindowID
- card.shineLayer.opacity = shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) ? 1 : 0
+ let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID
+ card.shineLayer.opacity = (shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused) ? 1 : 0
}
needsLayout = true
}
@@ -815,6 +820,12 @@
viewModel?.waterfallColumns.contains(where: { $0.appGroupIndex == keyboardFocusedAppGroupIndex }) != true {
self.keyboardFocusedAppGroupIndex = nil
}
+ if let keyboardFocusedWindowID,
+ viewModel?.waterfallColumns.contains(where: { column in
+ column.windows.contains { $0.window.id == keyboardFocusedWindowID }
+ }) != true {
+ self.keyboardFocusedWindowID = nil
+ }
if let currentSelection {
columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
}
@@ -830,6 +841,7 @@
currentSelection = viewModel?.initialSelection
columnSelectionHistory = [:]
keyboardFocusedAppGroupIndex = nil
+ keyboardFocusedWindowID = nil
keyboardCommandsApplied = []
lastKeyboardCommand = nil
lastKeyboardAppIndexCommand = nil
@@ -1256,6 +1268,7 @@
"selectedWindowIndex": effectiveSelection?.windowIndex ?? NSNull(),
"selectedWindowID": effectiveSelection?.windowID ?? NSNull(),
"keyboardFocusedAppGroupIndex": keyboardFocusedAppGroupIndex ?? NSNull(),
+ "keyboardFocusedWindowID": keyboardFocusedWindowID ?? NSNull(),
"keyboardCommandsApplied": keyboardCommandsApplied,
"lastKeyboardCommand": lastKeyboardCommand ?? NSNull(),
"lastKeyboardAppIndexCommand": lastKeyboardAppIndexCommand ?? NSNull(),
@@ -1450,6 +1463,7 @@
}
keyboardFocusedAppGroupIndex = appGroupIndex
+ keyboardFocusedWindowID = nil
lastKeyboardAppIndexCommand = appIndexCommand
selectionChangedByLastCommand = false
clearKeyboardBoundaryFeedback()
@@ -1533,6 +1547,7 @@
keyboardFocusedAppGroupIndex = targetAppGroupIndex
currentSelection = nextSelection
+ keyboardFocusedWindowID = nextSelection.windowID
columnSelectionHistory[nextSelection.appGroupIndex] = nextSelection.windowIndex
selectionChangedByLastCommand = previousSelection != nextSelection
clearKeyboardBoundaryFeedback()
@@ -2767,8 +2782,13 @@
container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor
container.borderWidth = 1
+ let cardsClip = CALayer()
+ cardsClip.masksToBounds = true
+ cardsClip.backgroundColor = NSColor.clear.cgColor
+
let header = CALayer()
header.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.68).cgColor
+ header.zPosition = 40
let appName = makeTextLayer(
string: column.app.name,
@@ -2785,6 +2805,10 @@
alignment: .right
)
+ appName.zPosition = 42
+ count.zPosition = 42
+
+ container.addSublayer(cardsClip)
container.addSublayer(header)
container.addSublayer(appName)
container.addSublayer(count)
@@ -2851,7 +2875,7 @@
card.addSublayer(state)
card.addSublayer(shine)
card.addSublayer(closeButton.containerLayer)
- container.addSublayer(card)
+ cardsClip.addSublayer(card)
return WaterfallCardLayers(
item: window,
@@ -2872,6 +2896,7 @@
return WaterfallColumnLayers(
column: column,
containerLayer: container,
+ cardsClipLayer: cardsClip,
headerLayer: header,
appNameLayer: appName,
countLayer: count,
@@ -3591,7 +3616,14 @@
x: locationInWaterfallContent.x - column.containerLayer.frame.minX,
y: locationInWaterfallContent.y - column.containerLayer.frame.minY
)
- if let card = column.cards.first(where: { $0.containerLayer.frame.contains(locationInColumn) }) {
+ guard column.cardsClipLayer.frame.contains(locationInColumn) else {
+ continue
+ }
+ let locationInCardsClip = CGPoint(
+ x: locationInColumn.x - column.cardsClipLayer.frame.minX,
+ y: locationInColumn.y - column.cardsClipLayer.frame.minY
+ )
+ if let card = column.cards.first(where: { $0.containerLayer.frame.contains(locationInCardsClip) }) {
return card
}
}
@@ -4154,6 +4186,8 @@
column.containerLayer.shadowOpacity = 0
column.containerLayer.shadowRadius = 0
column.containerLayer.shadowOffset = .zero
+ column.cardsClipLayer.frame = column.containerLayer.bounds
+ column.cardsClipLayer.masksToBounds = false
column.headerLayer.isHidden = true
column.appNameLayer.isHidden = true
column.countLayer.isHidden = true
@@ -4293,6 +4327,14 @@
column.countLayer.isHidden = false
let bounds = column.containerLayer.bounds
+ let cardsClipFrame = CGRect(
+ x: 0,
+ y: 0,
+ width: bounds.width,
+ height: max(1, bounds.height - WaterfallMetrics.headerHeight)
+ )
+ column.cardsClipLayer.frame = cardsClipFrame
+ column.cardsClipLayer.masksToBounds = true
let isSpaceFocused = spaceFocusWindowCount(for: column.column.appGroupIndex) > 0
let isHovered = column.column.appGroupIndex == effectiveHoveredAppGroupIndex
column.containerLayer.borderColor = (isSpaceFocused
@@ -4335,8 +4377,8 @@
waterfallColumnScrollOffsets[appGroupIndex] = verticalOffset
let cardWidth = max(1, bounds.width - WaterfallMetrics.columnHorizontalPadding * 2)
- let firstCardTop = bounds.height
- - WaterfallMetrics.headerHeight
+ let firstCardTop = cardsClipFrame.height
+ - WaterfallMetrics.selectedCardScaleOverflow
- WaterfallMetrics.columnVerticalPadding
+ verticalOffset
@@ -4360,10 +4402,11 @@
let bounds = card.containerLayer.bounds
let isSelected = card.item.window.id == effectiveSelection?.windowID
let isHovered = card.item.window.id == hoveredWindowID
+ let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID
let isAppLinked = isWaterfallCardAppLinked(card)
applyWaterfallCardVisualState(
card,
- selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered),
+ selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused,
hovered: isHovered,
appLinked: isAppLinked
)
@@ -4469,7 +4512,7 @@
? CGSize(width: 0, height: -5)
: .zero
card.containerLayer.zPosition = selected ? 20 : hovered ? 12 : appLinked ? 10 : isSpaceFocused ? 8 : 0
- let focusedScale: CGFloat = selected ? 1.015 : isSpaceFocused ? 1.012 : 1.008
+ let focusedScale: CGFloat = selected ? WaterfallMetrics.selectedCardScale : isSpaceFocused ? 1.012 : 1.008
card.containerLayer.transform = (selected || hovered || isSpaceFocused)
? CATransform3DMakeScale(focusedScale, focusedScale, 1)
: CATransform3DIdentity
@@ -4945,11 +4988,18 @@
x: locationInWaterfallContent.x - column.containerLayer.frame.minX,
y: locationInWaterfallContent.y - column.containerLayer.frame.minY
)
- for card in column.cards where card.containerLayer.frame.contains(locationInColumn) {
+ guard column.cardsClipLayer.frame.contains(locationInColumn) else {
+ continue
+ }
+ let locationInCardsClip = CGPoint(
+ x: locationInColumn.x - column.cardsClipLayer.frame.minX,
+ y: locationInColumn.y - column.cardsClipLayer.frame.minY
+ )
+ for card in column.cards where card.containerLayer.frame.contains(locationInCardsClip) {
guard card.closeButton.containerLayer.opacity > 0 else { continue }
let locationInCard = CGPoint(
- x: locationInColumn.x - card.containerLayer.frame.minX,
- y: locationInColumn.y - card.containerLayer.frame.minY
+ x: locationInCardsClip.x - card.containerLayer.frame.minX,
+ y: locationInCardsClip.y - card.containerLayer.frame.minY
)
if card.closeButton.containerLayer.frame.insetBy(dx: -8, dy: -8).contains(locationInCard) {
return .window(
@@ -4991,11 +5041,13 @@
x: waterfallLayer.frame.minX
+ waterfallContentLayer.frame.minX
+ column.containerLayer.frame.minX
+ + column.cardsClipLayer.frame.minX
+ card.containerLayer.frame.minX
+ card.closeButton.containerLayer.frame.midX,
y: waterfallLayer.frame.minY
+ waterfallContentLayer.frame.minY
+ column.containerLayer.frame.minY
+ + column.cardsClipLayer.frame.minY
+ card.containerLayer.frame.minY
+ card.closeButton.containerLayer.frame.midY
)
@@ -5123,7 +5175,7 @@
)
let contentHeight = CGFloat(column.cards.count) * WaterfallMetrics.cardHeight
+ CGFloat(max(0, column.cards.count - 1)) * WaterfallMetrics.cardGap
- return max(0, contentHeight - visibleHeight)
+ return max(0, contentHeight - visibleHeight + WaterfallMetrics.selectedCardScaleOverflow * 2)
}
private func clampedWaterfallColumnOffset(_ offset: CGFloat, appGroupIndex: Int) -> CGFloat {
@@ -5546,10 +5598,13 @@
let appGroupIndex = column.column.appGroupIndex
let isHovered = appGroupIndex == effectiveHoveredAppGroupIndex
let firstCardFrame = column.cards.first?.containerLayer.frame
- let headerToFirstCardGap = firstCardFrame.map {
+ let visibleFirstCardFrame = firstCardFrame.map {
+ $0.offsetBy(dx: column.cardsClipLayer.frame.minX, dy: column.cardsClipLayer.frame.minY)
+ }
+ let headerToFirstCardGap = visibleFirstCardFrame.map {
column.headerLayer.frame.minY - $0.maxY
}
- let topToFirstCardGap = firstCardFrame.map {
+ let topToFirstCardGap = visibleFirstCardFrame.map {
column.containerLayer.bounds.height - $0.maxY
}
@@ -5567,6 +5622,8 @@
"visibleFrame": dictionary(from: visibleFrame),
"headerFrame": dictionary(from: column.headerLayer.frame),
"headerBackgroundColor": colorDictionary(from: column.headerLayer.backgroundColor),
+ "cardsClipFrame": dictionary(from: column.cardsClipLayer.frame),
+ "cardsClipMasksToBounds": column.cardsClipLayer.masksToBounds,
"headerToFirstCardGap": headerToFirstCardGap.map(Double.init) ?? NSNull(),
"topToFirstCardGap": topToFirstCardGap.map(Double.init) ?? NSNull(),
"appNameFrame": dictionary(from: column.appNameLayer.frame),
@@ -5631,8 +5688,8 @@
private func waterfallCardReports(for column: WaterfallColumnLayers) -> [[String: Any]] {
column.cards.map { card in
let visibleFrame = card.containerLayer.frame.offsetBy(
- dx: column.containerLayer.frame.minX + waterfallContentLayer.frame.minX,
- dy: column.containerLayer.frame.minY
+ dx: column.containerLayer.frame.minX + column.cardsClipLayer.frame.minX + waterfallContentLayer.frame.minX,
+ dy: column.containerLayer.frame.minY + column.cardsClipLayer.frame.minY
)
let screenshotSource = screenshotSourcesByWindowID[card.item.window.id]
@@ -5650,6 +5707,7 @@
"isFullscreen": card.item.window.isFullscreen,
"identifierSource": "\(card.item.window.identifierSource)",
"isSelected": card.item.window.id == effectiveSelection?.windowID,
+ "isKeyboardFocused": card.item.window.id == keyboardFocusedWindowID,
"visualStates": waterfallCardVisualStates(for: card),
"thumbnailStrategy": thumbnailStrategyString(for: card.item.window),
"screenshotSource": screenshotSourceString(screenshotSource, for: card.item.window),
@@ -5768,12 +5826,16 @@
var states: [String] = []
let isSelected = card.item.window.id == effectiveSelection?.windowID
let isHovered = card.item.window.id == hoveredWindowID
+ let isKeyboardFocused = card.item.window.id == keyboardFocusedWindowID
let isAppLinked = isWaterfallCardAppLinked(card)
- if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) {
+ if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) || isKeyboardFocused {
states.append("selected")
} else if isSelected {
states.append("selectedVisualSuppressed")
}
+ if isKeyboardFocused {
+ states.append("keyboardFocused")
+ }
if isHovered {
states.append("hover")
}
diff --git a/C3.tools/round1-keyboard-navigation-fixture-qa.sh b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
index fa10a3e..380e376 100755
--- a/C3.tools/round1-keyboard-navigation-fixture-qa.sh
+++ b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
@@ -154,9 +154,12 @@
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")
require(selected_cards[0].get("windowID") == expected_selected_window_id, "selected Waterfall card must match selectedWindowID")
-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(selected_cards[0].get("isKeyboardFocused") is True, "keyboard-selected Waterfall card must expose keyboard focus")
+require("selected" in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must expose selected visual state")
+require("keyboardFocused" in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must expose keyboardFocused visual state")
+require("selectedVisualSuppressed" not in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must not suppress selected visual state")
+require(selected_cards[0].get("shineVisible") is True, "keyboard-selected Waterfall card must expose shine")
+require(selected_cards[0].get("zPosition", 0) > 0, "keyboard-selected Waterfall card must 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]
@@ -179,10 +182,19 @@
require(selected_column.get("verticalScrollOffset", 0) > 0, "down-arrow navigation must auto-scroll the selected window into view")
frame = selected_cards[0].get("visibleFrame", {})
+column_frame = selected_column.get("visibleFrame", {})
+header_frame = selected_column.get("headerFrame", {})
+cards_clip_frame = selected_column.get("cardsClipFrame", {})
+visibility_tolerance = 1
require(frame.get("x", -1) >= 0, "selected Waterfall card must be horizontally visible")
require(frame.get("x", 0) + frame.get("width", 0) <= root.get("waterfallVisibleWidth", 0) + 1, "selected Waterfall card must fit in visible Waterfall width")
-require(frame.get("y", -1) >= 0, "selected Waterfall card must be vertically visible")
+require(frame.get("y", -1) >= -visibility_tolerance, "selected Waterfall card must be vertically visible")
require(frame.get("y", 0) + frame.get("height", 0) <= root.get("waterfallFrame", {}).get("height", 0) + 1, "selected Waterfall card must fit in visible Waterfall height")
+require(selected_column.get("cardsClipMasksToBounds") is True, "vertical Waterfall column must clip cards below the pinned header")
+require(cards_clip_frame.get("y", -1) == 0, "cards clip layer must start below the column bottom edge")
+require(cards_clip_frame.get("height", 0) <= header_frame.get("y", 0) + 1, "cards clip layer must stop before the pinned header")
+header_min_y = column_frame.get("y", 0) + header_frame.get("y", 0)
+require(frame.get("y", 0) + frame.get("height", 0) <= header_min_y + 1, "selected Waterfall card must not overlap the pinned column header")
print(json.dumps({
"selectedAppGroupIndex": root.get("selectedAppGroupIndex"),
diff --git a/C3.tools/round1-main-ui-qa.sh b/C3.tools/round1-main-ui-qa.sh
index aa5c4d2..9c29bf2 100755
--- a/C3.tools/round1-main-ui-qa.sh
+++ b/C3.tools/round1-main-ui-qa.sh
@@ -109,6 +109,7 @@
run_step candidate-filter "$SCRIPT_DIR/round1-candidate-filter-fixture-qa.sh"
run_step screenshot-session "$SCRIPT_DIR/round1-screenshot-session-qa.sh"
run_step keyboard-navigation "$SCRIPT_DIR/round1-keyboard-navigation-fixture-qa.sh"
+run_step vertical-keyboard-app-focus "$SCRIPT_DIR/round1-vertical-keyboard-app-focus-fixture-qa.sh"
run_step window-activation "$SCRIPT_DIR/round1-window-activation-fixture-qa.sh"
run_step multi-page-identity "$SCRIPT_DIR/round1-multi-page-identity-fixture-qa.sh"
run_step mouse-interaction "$SCRIPT_DIR/round1-mouse-interaction-fixture-qa.sh"
diff --git a/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh b/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
index 1e9250e..c25cc9a 100755
--- a/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
+++ b/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
@@ -121,20 +121,24 @@
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"
+ local expected_focus_app_index="$3"
+ local expected_selected_app_index="$4"
+ local expected_window_index="$5"
+ local expected_boundary_count="$6"
+ local expected_boundary_direction="$7"
+ local expect_selection_changed="$8"
+ local expect_keyboard_window_focus="$9"
/usr/bin/python3 - \
"$report" \
"$expected_sequence" \
- "$expected_app_index" \
+ "$expected_focus_app_index" \
+ "$expected_selected_app_index" \
"$expected_window_index" \
"$expected_boundary_count" \
"$expected_boundary_direction" \
"$expect_selection_changed" \
+ "$expect_keyboard_window_focus" \
"$FIXTURE_APP_COUNT" \
"$FIXTURE_WINDOWS_PER_APP" <<'PY'
import json
@@ -142,13 +146,15 @@
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])
+expected_focus_app_index = int(sys.argv[3])
+expected_selected_app_index = int(sys.argv[4])
+expected_window_index = int(sys.argv[5])
+expected_boundary_count = int(sys.argv[6])
+expected_boundary_direction = None if sys.argv[7] == "none" else sys.argv[7]
+expect_selection_changed = sys.argv[8] == "true"
+expect_keyboard_window_focus = sys.argv[9] == "true"
+fixture_app_count = int(sys.argv[10])
+fixture_windows_per_app = int(sys.argv[11])
with open(path, "r", encoding="utf-8") as file:
report = json.load(file)
@@ -162,8 +168,9 @@
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)
+focus_item = next((item for item in items if item.get("index") == expected_focus_app_index), None)
+focus_column = next((column for column in columns if column.get("appGroupIndex") == expected_focus_app_index), None)
+selected_column = next((column for column in columns if column.get("appGroupIndex") == expected_selected_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")
@@ -171,22 +178,43 @@
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("keyboardFocusedAppGroupIndex") == expected_focus_app_index, "keyboard focus must land on expected App")
+require(root.get("hoveredAppGroupIndex") == expected_focus_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(focus_item is not None, "focused App Shelf item must exist")
+require(focus_column is not None, "focused Waterfall column must exist")
+require(selected_column is not None, "selected Waterfall column must exist")
+selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
+selected_card = selected_column.get("cards", [])[expected_window_index]
+require(focus_item.get("isHovered") is True, "focused App Shelf item must expose hover")
+require("hover" in focus_item.get("visualStates", []), "focused App Shelf item must expose hover visual")
+require(focus_column.get("isHovered") is True, "focused Waterfall column must expose hover")
+require("hover" in focus_column.get("visualStates", []), "focused Waterfall column must expose hover visual")
+require(root.get("selectedAppGroupIndex") == expected_selected_app_index, "selected App must match expected selected App")
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 expect_keyboard_window_focus:
+ require(len(selected_cards) == 1, "keyboard window focus run must expose exactly one selected Waterfall card")
+ require(selected_card.get("isSelected") is True, "expected selected card must expose selection")
+ require(selected_card.get("isKeyboardFocused") is True, "expected selected card must expose keyboard focus")
+ require(root.get("keyboardFocusedWindowID") == selected_card.get("windowID"), "keyboardFocusedWindowID must match selected card")
+ require("selected" in selected_card.get("visualStates", []), "keyboard-focused selected card must expose selected visual")
+ require("keyboardFocused" in selected_card.get("visualStates", []), "keyboard-focused selected card must expose keyboardFocused state")
+ require(selected_card.get("shineVisible") is True, "keyboard-focused selected card must expose shine")
+ header_frame = selected_column.get("headerFrame", {})
+ clip_frame = selected_column.get("cardsClipFrame", {})
+ column_frame = selected_column.get("visibleFrame", {})
+ card_frame = selected_card.get("visibleFrame", {})
+ require(selected_column.get("cardsClipMasksToBounds") is True, "vertical cards must be clipped below pinned header")
+ require(clip_frame.get("height", 0) <= header_frame.get("y", 0) + 1, "cards clip must stop before pinned header")
+ header_min_y = column_frame.get("y", 0) + header_frame.get("y", 0)
+ require(card_frame.get("y", 0) + card_frame.get("height", 0) <= header_min_y + 1, "keyboard-focused card must not overlap pinned header")
+else:
+ require(root.get("keyboardFocusedWindowID") is None, "App-only keyboard focus must not expose keyboardFocusedWindowID")
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")
@@ -197,7 +225,9 @@
print(json.dumps({
"commands": root.get("keyboardCommandsApplied"),
"keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"),
+ "selectedAppGroupIndex": root.get("selectedAppGroupIndex"),
"selectedWindowIndex": root.get("selectedWindowIndex"),
+ "keyboardFocusedWindowID": root.get("keyboardFocusedWindowID"),
"boundaryBounceCount": root.get("boundaryBounceCount"),
"lastBoundaryBounceDirection": root.get("lastBoundaryBounceDirection")
}, indent=2, ensure_ascii=False))
@@ -207,11 +237,13 @@
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 expected_focus_app_index="$3"
+ local expected_selected_app_index="$4"
+ local expected_window_index="$5"
+ local expected_boundary_count="$6"
+ local expected_boundary_direction="$7"
+ local expect_selection_changed="$8"
+ local expect_keyboard_window_focus="$9"
local report="$REPORT_DIR/round01-vertical-keyboard-$name-report.json"
local expected_sequence
expected_sequence="$(normalize_keyboard_sequence "$sequence")"
@@ -235,11 +267,13 @@
assert_report \
"$report" \
"$expected_sequence" \
- "$expected_app_index" \
+ "$expected_focus_app_index" \
+ "$expected_selected_app_index" \
"$expected_window_index" \
"$expected_boundary_count" \
"$expected_boundary_direction" \
- "$expect_selection_changed"
+ "$expect_selection_changed" \
+ "$expect_keyboard_window_focus"
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
@@ -254,14 +288,23 @@
if [ "$FIXTURE_WINDOWS_PER_APP" -lt 8 ]; then
fail "fixture windows per app must be at least 8"
fi
+symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+if [ "$FIXTURE_APP_COUNT" -gt "${#symbols}" ]; then
+ fail "fixture app count cannot exceed supported App Shelf index symbols"
+fi
+
+LAST_APP_INDEX=$((FIXTURE_APP_COUNT - 1))
+LAST_APP_SYMBOL="${symbols:LAST_APP_INDEX:1}"
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"
+run_case "index-hover-only" "A" 10 0 0 0 "none" "false" "false"
+run_case "left-wrap-to-last" "1,left" "$LAST_APP_INDEX" 0 0 0 "none" "false" "false"
+run_case "right-wrap-to-first" "$LAST_APP_SYMBOL,right" 0 0 0 0 "none" "false" "false"
+run_case "bottom-boundary" "3,down,down,down,down,down,down,down,down,down" 2 2 7 1 "down" "false" "true"
+run_case "top-boundary" "4,down,up" 3 3 0 1 "up" "false" "true"
trap - EXIT
cleanup
--
Gitblit v1.9.3