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",