Skip to content

Commit 74ae9ae

Browse files
bodymovinbodymovin
andcommitted
fix(runtime-focus): resolve focus to first leaf on direct focus (#12974) d607980229
Co-authored-by: hernan <hernan@rive.app>
1 parent b0b126f commit 74ae9ae

3 files changed

Lines changed: 131 additions & 1 deletion

File tree

.rive_head

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
c372d0d9d43f0adaba357ab55d212828b0a156a5
1+
d6079802297ddfbac2ca28c2d467d5e2686010f5

src/input/focus_manager.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ static bool focusNodeEligibleForTraversal(FocusNode* node)
4646
return focusNodeEligibleForFocus(node);
4747
}
4848

49+
// Defined later in this file; used by setFocus to descend a scope to its first
50+
// eligible leaf.
51+
static FocusNode* getFirstLeaf(FocusNode* node, const FocusManager* manager);
52+
4953
void FocusManager::dropFocusIfFocusTargetHidden()
5054
{
5155
if (m_primaryFocus == nullptr)
@@ -106,6 +110,24 @@ void FocusManager::removeManager(rcp<FocusNode> node)
106110

107111
void FocusManager::setFocus(rcp<FocusNode> node)
108112
{
113+
// Focus always rests on a leaf: if handed a scope (a node with eligible
114+
// traversable descendants), descend to its first eligible leaf — matching
115+
// Tab/arrow traversal, which never lands focus on a scope. Falls back to
116+
// the node itself when it has no eligible leaf.
117+
//
118+
// Gate the descent on the requested target being eligible for focus, so a
119+
// programmatic focus on an ineligible target (canFocus==false, collapsed,
120+
// hidden, opacity 0, ...) stays a no-op as before, rather than reaching an
121+
// eligible descendant and bypassing the early-return guards below.
122+
if (node != nullptr && focusNodeEligibleForFocus(node.get()))
123+
{
124+
FocusNode* leaf = getFirstLeaf(node.get(), this);
125+
if (leaf != nullptr)
126+
{
127+
node = ref_rcp(leaf);
128+
}
129+
}
130+
109131
if (node == m_primaryFocus)
110132
{
111133
return;

tests/unit_tests/runtime/focus_test.cpp

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,114 @@ TEST_CASE("StateMachineInstance::clearFocus clears internal focus manager",
981981
CHECK(state.expectsKeyboardInput == false);
982982
}
983983

984+
TEST_CASE("FocusManager setFocus on a scope descends to first leaf",
985+
"[FocusManager]")
986+
{
987+
FocusManager manager;
988+
auto scope = make_rcp<FocusNode>();
989+
auto leaf1 = make_rcp<FocusNode>();
990+
auto leaf2 = make_rcp<FocusNode>();
991+
992+
manager.addChild(nullptr, scope);
993+
manager.addChild(scope, leaf1);
994+
manager.addChild(scope, leaf2);
995+
996+
// Focusing the scope resolves to its first eligible leaf.
997+
manager.setFocus(scope);
998+
CHECK(manager.primaryFocus() == leaf1);
999+
}
1000+
1001+
TEST_CASE("FocusManager setFocus on a scope descends depth-first",
1002+
"[FocusManager]")
1003+
{
1004+
FocusManager manager;
1005+
auto scope = make_rcp<FocusNode>();
1006+
auto row = make_rcp<FocusNode>();
1007+
auto leaf = make_rcp<FocusNode>();
1008+
auto sibling = make_rcp<FocusNode>();
1009+
1010+
manager.addChild(nullptr, scope);
1011+
manager.addChild(scope, row);
1012+
manager.addChild(row, leaf);
1013+
manager.addChild(scope, sibling);
1014+
1015+
// Depth-first: first leaf is the leaf nested under the first child (row).
1016+
manager.setFocus(scope);
1017+
CHECK(manager.primaryFocus() == leaf);
1018+
}
1019+
1020+
TEST_CASE("FocusManager setFocus on a scope with no eligible leaf falls back",
1021+
"[FocusManager]")
1022+
{
1023+
FocusManager manager;
1024+
auto scope = make_rcp<FocusNode>();
1025+
auto child = make_rcp<FocusNode>();
1026+
// Child cannot be traversed, so the scope has no eligible leaf to descend
1027+
// to. The scope itself remains the focus target (preserves prior behavior).
1028+
child->canTraverse(false);
1029+
1030+
manager.addChild(nullptr, scope);
1031+
manager.addChild(scope, child);
1032+
1033+
manager.setFocus(scope);
1034+
CHECK(manager.primaryFocus() == scope);
1035+
}
1036+
1037+
TEST_CASE("FocusManager setFocus on an ineligible scope is a no-op",
1038+
"[FocusManager]")
1039+
{
1040+
FocusManager manager;
1041+
auto scope = make_rcp<FocusNode>();
1042+
auto leaf = make_rcp<FocusNode>();
1043+
// The requested target itself cannot be focused. Descent must not reach an
1044+
// eligible descendant — focus stays unchanged (no-op), matching the prior
1045+
// early-return guard behavior.
1046+
scope->canFocus(false);
1047+
1048+
manager.addChild(nullptr, scope);
1049+
manager.addChild(scope, leaf);
1050+
1051+
manager.setFocus(scope);
1052+
CHECK(manager.primaryFocus() == nullptr);
1053+
}
1054+
1055+
TEST_CASE("FocusManager setFocus on a leaf is unchanged", "[FocusManager]")
1056+
{
1057+
FocusManager manager;
1058+
auto scope = make_rcp<FocusNode>();
1059+
auto leaf1 = make_rcp<FocusNode>();
1060+
auto leaf2 = make_rcp<FocusNode>();
1061+
1062+
manager.addChild(nullptr, scope);
1063+
manager.addChild(scope, leaf1);
1064+
manager.addChild(scope, leaf2);
1065+
1066+
// Directly focusing a leaf still focuses that exact leaf (no-op descent).
1067+
manager.setFocus(leaf2);
1068+
CHECK(manager.primaryFocus() == leaf2);
1069+
}
1070+
1071+
TEST_CASE("FocusManager Tab after focusing a scope traverses leaf siblings",
1072+
"[FocusManager]")
1073+
{
1074+
FocusManager manager;
1075+
auto scope = make_rcp<FocusNode>();
1076+
auto leaf1 = make_rcp<FocusNode>();
1077+
auto leaf2 = make_rcp<FocusNode>();
1078+
1079+
manager.addChild(nullptr, scope);
1080+
manager.addChild(scope, leaf1);
1081+
manager.addChild(scope, leaf2);
1082+
1083+
// Focusing the scope lands on the first leaf; Tab then advances to the
1084+
// scope's next leaf rather than skipping the scope's children.
1085+
manager.setFocus(scope);
1086+
CHECK(manager.primaryFocus() == leaf1);
1087+
1088+
manager.focusNext();
1089+
CHECK(manager.primaryFocus() == leaf2);
1090+
}
1091+
9841092
} // namespace rive
9851093

9861094
TEST_CASE("FocusManager skips collapsed nodes and fully transparent nodes",

0 commit comments

Comments
 (0)