fix(runtime-focus): resolve focus to first leaf on direct focus (#12974) d607980229 Co-authored-by: hernan <hernan@rive.app>
diff --git a/.rive_head b/.rive_head index d4162ac1..eb31549 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -c372d0d9d43f0adaba357ab55d212828b0a156a5 +d6079802297ddfbac2ca28c2d467d5e2686010f5
diff --git a/src/input/focus_manager.cpp b/src/input/focus_manager.cpp index 23399f0..3a5cd47 100644 --- a/src/input/focus_manager.cpp +++ b/src/input/focus_manager.cpp
@@ -46,6 +46,10 @@ return focusNodeEligibleForFocus(node); } +// Defined later in this file; used by setFocus to descend a scope to its first +// eligible leaf. +static FocusNode* getFirstLeaf(FocusNode* node, const FocusManager* manager); + void FocusManager::dropFocusIfFocusTargetHidden() { if (m_primaryFocus == nullptr) @@ -106,6 +110,24 @@ void FocusManager::setFocus(rcp<FocusNode> node) { + // Focus always rests on a leaf: if handed a scope (a node with eligible + // traversable descendants), descend to its first eligible leaf — matching + // Tab/arrow traversal, which never lands focus on a scope. Falls back to + // the node itself when it has no eligible leaf. + // + // Gate the descent on the requested target being eligible for focus, so a + // programmatic focus on an ineligible target (canFocus==false, collapsed, + // hidden, opacity 0, ...) stays a no-op as before, rather than reaching an + // eligible descendant and bypassing the early-return guards below. + if (node != nullptr && focusNodeEligibleForFocus(node.get())) + { + FocusNode* leaf = getFirstLeaf(node.get(), this); + if (leaf != nullptr) + { + node = ref_rcp(leaf); + } + } + if (node == m_primaryFocus) { return;
diff --git a/tests/unit_tests/runtime/focus_test.cpp b/tests/unit_tests/runtime/focus_test.cpp index 5be91cb..e688758 100644 --- a/tests/unit_tests/runtime/focus_test.cpp +++ b/tests/unit_tests/runtime/focus_test.cpp
@@ -981,6 +981,114 @@ CHECK(state.expectsKeyboardInput == false); } +TEST_CASE("FocusManager setFocus on a scope descends to first leaf", + "[FocusManager]") +{ + FocusManager manager; + auto scope = make_rcp<FocusNode>(); + auto leaf1 = make_rcp<FocusNode>(); + auto leaf2 = make_rcp<FocusNode>(); + + manager.addChild(nullptr, scope); + manager.addChild(scope, leaf1); + manager.addChild(scope, leaf2); + + // Focusing the scope resolves to its first eligible leaf. + manager.setFocus(scope); + CHECK(manager.primaryFocus() == leaf1); +} + +TEST_CASE("FocusManager setFocus on a scope descends depth-first", + "[FocusManager]") +{ + FocusManager manager; + auto scope = make_rcp<FocusNode>(); + auto row = make_rcp<FocusNode>(); + auto leaf = make_rcp<FocusNode>(); + auto sibling = make_rcp<FocusNode>(); + + manager.addChild(nullptr, scope); + manager.addChild(scope, row); + manager.addChild(row, leaf); + manager.addChild(scope, sibling); + + // Depth-first: first leaf is the leaf nested under the first child (row). + manager.setFocus(scope); + CHECK(manager.primaryFocus() == leaf); +} + +TEST_CASE("FocusManager setFocus on a scope with no eligible leaf falls back", + "[FocusManager]") +{ + FocusManager manager; + auto scope = make_rcp<FocusNode>(); + auto child = make_rcp<FocusNode>(); + // Child cannot be traversed, so the scope has no eligible leaf to descend + // to. The scope itself remains the focus target (preserves prior behavior). + child->canTraverse(false); + + manager.addChild(nullptr, scope); + manager.addChild(scope, child); + + manager.setFocus(scope); + CHECK(manager.primaryFocus() == scope); +} + +TEST_CASE("FocusManager setFocus on an ineligible scope is a no-op", + "[FocusManager]") +{ + FocusManager manager; + auto scope = make_rcp<FocusNode>(); + auto leaf = make_rcp<FocusNode>(); + // The requested target itself cannot be focused. Descent must not reach an + // eligible descendant — focus stays unchanged (no-op), matching the prior + // early-return guard behavior. + scope->canFocus(false); + + manager.addChild(nullptr, scope); + manager.addChild(scope, leaf); + + manager.setFocus(scope); + CHECK(manager.primaryFocus() == nullptr); +} + +TEST_CASE("FocusManager setFocus on a leaf is unchanged", "[FocusManager]") +{ + FocusManager manager; + auto scope = make_rcp<FocusNode>(); + auto leaf1 = make_rcp<FocusNode>(); + auto leaf2 = make_rcp<FocusNode>(); + + manager.addChild(nullptr, scope); + manager.addChild(scope, leaf1); + manager.addChild(scope, leaf2); + + // Directly focusing a leaf still focuses that exact leaf (no-op descent). + manager.setFocus(leaf2); + CHECK(manager.primaryFocus() == leaf2); +} + +TEST_CASE("FocusManager Tab after focusing a scope traverses leaf siblings", + "[FocusManager]") +{ + FocusManager manager; + auto scope = make_rcp<FocusNode>(); + auto leaf1 = make_rcp<FocusNode>(); + auto leaf2 = make_rcp<FocusNode>(); + + manager.addChild(nullptr, scope); + manager.addChild(scope, leaf1); + manager.addChild(scope, leaf2); + + // Focusing the scope lands on the first leaf; Tab then advances to the + // scope's next leaf rather than skipping the scope's children. + manager.setFocus(scope); + CHECK(manager.primaryFocus() == leaf1); + + manager.focusNext(); + CHECK(manager.primaryFocus() == leaf2); +} + } // namespace rive TEST_CASE("FocusManager skips collapsed nodes and fully transparent nodes",