blob: 91cf7cffa59c5698c3fccddbfa8fbb852b037132 [file]
#include <catch.hpp>
#include "rive/animation/focus_action_clear.hpp"
#include "rive/animation/focus_action_traversal.hpp"
#include "rive/animation/transition_condition_op.hpp"
#include "rive/animation/transition_focus_condition.hpp"
#include "rive/animation/transition_property_component_comparator.hpp"
#include "rive/animation/listener_invocation.hpp"
#include "rive/animation/nested_state_machine.hpp"
#include "rive/animation/state_machine.hpp"
#include "rive/animation/state_machine_instance.hpp"
#include "rive/artboard.hpp"
#include "rive/artboard_component_list.hpp"
#include "rive/focus_data.hpp"
#include "rive/node.hpp"
#include "rive/input/focus_node.hpp"
#include "rive/input/focus_manager.hpp"
#include "utils/no_op_factory.hpp"
#include "utils/serializing_factory.hpp"
#include "rive_file_reader.hpp"
#include "rive/nested_artboard.hpp"
#include "rive/viewmodel/viewmodel_instance_artboard.hpp"
#include "rive/viewmodel/viewmodel_instance_boolean.hpp"
#include "rive/viewmodel/viewmodel_instance_number.hpp"
#include "rive/animation/listener_invocation.hpp"
#include "rive/input/gamepad_snapshot.hpp"
namespace rive
{
// Mock Focusable for testing
class MockFocusable : public Focusable
{
public:
int keyInputCount = 0;
int textInputCount = 0;
int gamepadDispatchCount = 0;
int focusedCount = 0;
int blurredCount = 0;
std::string lastText;
Key lastKey = Key::a;
bool returnValue = false;
bool keyInput(Key key,
KeyModifiers modifiers,
bool isPressed,
bool isRepeat) override
{
keyInputCount++;
lastKey = key;
return returnValue;
}
bool textInput(const std::string& text) override
{
textInputCount++;
lastText = text;
return returnValue;
}
bool gamepadDispatch(const ListenerInvocation&,
ScriptedDrawable** = nullptr) override
{
gamepadDispatchCount++;
return returnValue;
}
void focused() override { focusedCount++; }
void blurred() override { blurredCount++; }
bool eligible = true;
bool isEligibleForFocusTraversal() const override { return eligible; }
};
// =============================================================================
// FocusNode Tests
// =============================================================================
TEST_CASE("FocusNode default properties", "[FocusNode]")
{
auto node = make_rcp<FocusNode>();
CHECK(node->canFocus() == true);
CHECK(node->canTouch() == true);
CHECK(node->canTraverse() == true);
CHECK(node->tabIndex() == 0);
CHECK(node->edgeBehavior() == EdgeBehavior::parentScope);
CHECK(node->focusable() == nullptr);
CHECK(node->parent() == nullptr);
CHECK(node->children().empty());
CHECK(node->isScope() == false);
CHECK(node->hasFocus() == false);
CHECK(node->manager() == nullptr);
}
TEST_CASE("FocusNode property setters", "[FocusNode]")
{
auto node = make_rcp<FocusNode>();
node->canFocus(false);
CHECK(node->canFocus() == false);
node->canTouch(false);
CHECK(node->canTouch() == false);
node->canTraverse(false);
CHECK(node->canTraverse() == false);
node->tabIndex(42);
CHECK(node->tabIndex() == 42);
node->edgeBehavior(EdgeBehavior::closedLoop);
CHECK(node->edgeBehavior() == EdgeBehavior::closedLoop);
node->edgeBehavior(EdgeBehavior::stop);
CHECK(node->edgeBehavior() == EdgeBehavior::stop);
}
TEST_CASE("FocusNode with Focusable", "[FocusNode]")
{
MockFocusable focusable;
auto node = make_rcp<FocusNode>(&focusable);
CHECK(node->focusable() == &focusable);
// Test input delegation
node->keyInput(Key::a, KeyModifiers::none, true, false);
CHECK(focusable.keyInputCount == 1);
CHECK(focusable.lastKey == Key::a);
node->textInput("hello");
CHECK(focusable.textInputCount == 1);
CHECK(focusable.lastText == "hello");
// Test lifecycle delegation
node->focused();
CHECK(focusable.focusedCount == 1);
node->blurred();
CHECK(focusable.blurredCount == 1);
}
TEST_CASE("FocusNode without Focusable doesn't crash", "[FocusNode]")
{
auto node = make_rcp<FocusNode>();
// These should not crash
CHECK(node->keyInput(Key::a, KeyModifiers::none, true, false) == false);
CHECK(node->textInput("hello") == false);
node->focused();
node->blurred();
}
TEST_CASE("FocusNode setFocusable/clearFocusable", "[FocusNode]")
{
MockFocusable focusable;
auto node = make_rcp<FocusNode>();
CHECK(node->focusable() == nullptr);
node->setFocusable(&focusable);
CHECK(node->focusable() == &focusable);
node->clearFocusable();
CHECK(node->focusable() == nullptr);
}
TEST_CASE("FocusNode hierarchy", "[FocusNode]")
{
auto parent = make_rcp<FocusNode>();
auto child1 = make_rcp<FocusNode>();
auto child2 = make_rcp<FocusNode>();
parent->addChild(child1);
parent->addChild(child2);
CHECK(child1->parent() == parent.get());
CHECK(child2->parent() == parent.get());
CHECK(parent->children().size() == 2);
CHECK(parent->isScope() == true);
parent->removeChild(child1);
CHECK(child1->parent() == nullptr);
CHECK(parent->children().size() == 1);
}
// =============================================================================
// FocusManager Tests
// =============================================================================
TEST_CASE("FocusManager basic focus operations", "[FocusManager]")
{
FocusManager manager;
MockFocusable focusable;
auto node = make_rcp<FocusNode>(&focusable);
CHECK(manager.primaryFocus() == nullptr);
manager.addChild(nullptr, node);
manager.setFocus(node);
CHECK(manager.primaryFocus() == node);
CHECK(manager.hasFocus(node) == true);
CHECK(manager.hasPrimaryFocus(node) == true);
CHECK(focusable.focusedCount == 1);
manager.clearFocus();
CHECK(manager.primaryFocus() == nullptr);
CHECK(focusable.blurredCount == 1);
}
TEST_CASE("FocusManager focus change notifications", "[FocusManager]")
{
FocusManager manager;
MockFocusable focusable1, focusable2;
auto node1 = make_rcp<FocusNode>(&focusable1);
auto node2 = make_rcp<FocusNode>(&focusable2);
manager.addChild(nullptr, node1);
manager.addChild(nullptr, node2);
manager.setFocus(node1);
CHECK(focusable1.focusedCount == 1);
CHECK(focusable1.blurredCount == 0);
manager.setFocus(node2);
CHECK(focusable1.blurredCount == 1);
CHECK(focusable2.focusedCount == 1);
}
TEST_CASE("FocusManager respects canFocus", "[FocusManager]")
{
FocusManager manager;
auto node = make_rcp<FocusNode>();
node->canFocus(false);
manager.addChild(nullptr, node);
manager.setFocus(node);
CHECK(manager.primaryFocus() == nullptr);
}
TEST_CASE("FocusManager hierarchy", "[FocusManager]")
{
FocusManager manager;
auto parent = make_rcp<FocusNode>();
auto child1 = make_rcp<FocusNode>();
auto child2 = make_rcp<FocusNode>();
manager.addChild(nullptr, parent);
manager.addChild(parent, child1);
manager.addChild(parent, child2);
CHECK(parent->parent() == nullptr);
CHECK(child1->parent() == parent.get());
CHECK(child2->parent() == parent.get());
CHECK(parent->isScope() == true);
CHECK(child1->isScope() == false);
const auto& children = parent->children();
CHECK(children.size() == 2);
// Manager reference is set on all nodes
CHECK(parent->manager() == &manager);
CHECK(child1->manager() == &manager);
CHECK(child2->manager() == &manager);
}
TEST_CASE("FocusManager hasFocus with descendants", "[FocusManager]")
{
FocusManager manager;
auto parent = make_rcp<FocusNode>();
auto child = make_rcp<FocusNode>();
manager.addChild(nullptr, parent);
manager.addChild(parent, child);
manager.setFocus(child);
// Manager queries should work
CHECK(manager.hasFocus(parent) == true);
CHECK(manager.hasPrimaryFocus(parent) == false);
CHECK(manager.hasFocus(child) == true);
CHECK(manager.hasPrimaryFocus(child) == true);
// Node's hasFocus flag should be set for focused node and ancestors
CHECK(parent->hasFocus() == true);
CHECK(child->hasFocus() == true);
}
TEST_CASE("FocusManager removeChild clears focus", "[FocusManager]")
{
FocusManager manager;
MockFocusable focusable;
auto node = make_rcp<FocusNode>(&focusable);
manager.addChild(nullptr, node);
manager.setFocus(node);
CHECK(manager.primaryFocus() == node);
manager.removeChild(node);
CHECK(manager.primaryFocus() == nullptr);
CHECK(focusable.blurredCount == 1);
}
TEST_CASE(
"List row reparent: FocusNode removeFromParent preserves primary focus",
"[FocusManager][list]")
{
FocusManager manager;
MockFocusable fLeaf;
auto scope = make_rcp<FocusNode>(nullptr);
scope->canFocus(true);
scope->canTraverse(true);
auto row = make_rcp<FocusNode>(nullptr);
row->canFocus(true);
row->canTraverse(true);
auto leaf = make_rcp<FocusNode>(&fLeaf);
manager.addChild(nullptr, scope);
manager.addChild(scope, row);
manager.addChild(row, leaf);
manager.setFocus(leaf);
CHECK(manager.primaryFocus() == leaf);
row->removeFromParent();
CHECK(manager.primaryFocus() == leaf);
manager.addChild(scope, row, 0);
CHECK(manager.primaryFocus() == leaf);
CHECK(fLeaf.blurredCount == 0);
}
TEST_CASE("hasFocusableContent invalidates when canFocus toggles after caching",
"[FocusManager]")
{
FocusManager manager;
// Both structural: no focusable backing, canFocus=false.
auto scope = FocusNode::makeStructuralScope();
auto child = FocusNode::makeStructuralScope();
manager.addChild(nullptr, scope);
manager.addChild(scope, child);
// Compute + cache the "no focusable content" answer.
CHECK(manager.hasFocusableContent() == false);
// A canFocus flip on a cached tree must be reflected.
child->canFocus(true);
CHECK(manager.hasFocusableContent() == true);
child->canFocus(false);
CHECK(manager.hasFocusableContent() == false);
}
TEST_CASE(
"hasFocusableContent invalidates when focusable backing toggles after "
"caching",
"[FocusManager]")
{
FocusManager manager;
MockFocusable focusable;
auto scope = FocusNode::makeStructuralScope();
auto child = FocusNode::makeStructuralScope();
manager.addChild(nullptr, scope);
manager.addChild(scope, child);
CHECK(manager.hasFocusableContent() == false);
// Gaining a focusable backing counts even while canFocus stays false.
child->setFocusable(&focusable);
CHECK(manager.hasFocusableContent() == true);
child->clearFocusable();
CHECK(manager.hasFocusableContent() == false);
}
TEST_CASE("hasFocusableContent invalidates when a backed node is added then "
"removed",
"[FocusManager]")
{
// Mirrors a data-bound nested-artboard swap: a structural scope gains a
// focusable node on swap-in, then loses it on swap-out.
FocusManager manager;
MockFocusable focusable;
auto scope = FocusNode::makeStructuralScope();
manager.addChild(nullptr, scope);
CHECK(manager.hasFocusableContent() == false);
auto backed = make_rcp<FocusNode>(&focusable);
manager.addChild(scope, backed);
CHECK(manager.hasFocusableContent() == true);
manager.removeChild(backed);
CHECK(manager.hasFocusableContent() == false);
}
TEST_CASE("hasFocusableContent invalidates when the last root is erased",
"[FocusManager]")
{
// eraseRoot is the only invalidation for a root removed while migrating to
// another manager; exercise it directly via a re-parent to a second
// manager, which erases the node from the first manager's root list.
FocusManager first;
FocusManager second;
auto node = make_rcp<FocusNode>();
node->canFocus(true);
first.addChild(nullptr, node);
CHECK(first.hasFocusableContent() == true);
// Migrating the root out of `first` empties its tree.
second.addChild(nullptr, node);
CHECK(first.hasFocusableContent() == false);
CHECK(second.hasFocusableContent() == true);
}
TEST_CASE("FocusManager input routing", "[FocusManager]")
{
FocusManager manager;
MockFocusable focusable;
focusable.returnValue = true;
auto node = make_rcp<FocusNode>(&focusable);
manager.addChild(nullptr, node);
// No focus, input not handled
CHECK(manager.keyInput(Key::a, KeyModifiers::none, true, false) == false);
CHECK(manager.textInput("hello") == false);
GamepadSnapshot snap{};
snap.deviceId = 1;
snap.buttonMask = 1;
CHECK(manager.gamepadDispatch(ListenerInvocation::gamepadConnected(snap)) ==
false);
manager.setFocus(node);
// With focus, input is routed
CHECK(manager.keyInput(Key::b, KeyModifiers::none, true, false) == true);
CHECK(focusable.keyInputCount == 1);
CHECK(focusable.lastKey == Key::b);
CHECK(manager.textInput("world") == true);
CHECK(focusable.textInputCount == 1);
CHECK(focusable.lastText == "world");
CHECK(manager.gamepadDispatch(ListenerInvocation::gamepadConnected(snap)) ==
true);
CHECK(focusable.gamepadDispatchCount == 1);
}
TEST_CASE("FocusManager traversal basic", "[FocusManager]")
{
FocusManager manager;
MockFocusable f1, f2, f3;
auto node1 = make_rcp<FocusNode>(&f1);
auto node2 = make_rcp<FocusNode>(&f2);
auto node3 = make_rcp<FocusNode>(&f3);
manager.addChild(nullptr, node1);
manager.addChild(nullptr, node2);
manager.addChild(nullptr, node3);
// Focus first node
manager.setFocus(node1);
CHECK(manager.primaryFocus() == node1);
// Navigate forward
manager.focusNext();
CHECK(manager.primaryFocus() == node2);
manager.focusNext();
CHECK(manager.primaryFocus() == node3);
// Navigate backward
manager.focusPrevious();
CHECK(manager.primaryFocus() == node2);
}
TEST_CASE("FocusManager traversal with tabIndex", "[FocusManager]")
{
FocusManager manager;
auto node1 = make_rcp<FocusNode>();
auto node2 = make_rcp<FocusNode>();
auto node3 = make_rcp<FocusNode>();
node1->tabIndex(3);
node2->tabIndex(1);
node3->tabIndex(2);
manager.addChild(nullptr, node1);
manager.addChild(nullptr, node2);
manager.addChild(nullptr, node3);
// Start with no focus, focusNext should pick first by tabIndex
manager.focusNext();
CHECK(manager.primaryFocus() == node2); // tabIndex 1
manager.focusNext();
CHECK(manager.primaryFocus() == node3); // tabIndex 2
manager.focusNext();
CHECK(manager.primaryFocus() == node1); // tabIndex 3
}
TEST_CASE("FocusManager traversal skips non-traversable", "[FocusManager]")
{
FocusManager manager;
auto node1 = make_rcp<FocusNode>();
auto node2 = make_rcp<FocusNode>();
auto node3 = make_rcp<FocusNode>();
node2->canTraverse(false);
manager.addChild(nullptr, node1);
manager.addChild(nullptr, node2);
manager.addChild(nullptr, node3);
manager.setFocus(node1);
manager.focusNext();
// Should skip node2 and go to node3
CHECK(manager.primaryFocus() == node3);
}
TEST_CASE("FocusManager edge behavior closedLoop", "[FocusManager]")
{
FocusManager manager;
auto scope = make_rcp<FocusNode>();
auto node1 = make_rcp<FocusNode>();
auto node2 = make_rcp<FocusNode>();
scope->edgeBehavior(EdgeBehavior::closedLoop);
manager.addChild(nullptr, scope);
manager.addChild(scope, node1);
manager.addChild(scope, node2);
manager.setFocus(node2);
manager.focusNext();
// Should wrap to first
CHECK(manager.primaryFocus() == node1);
}
TEST_CASE("FocusManager edge behavior stop", "[FocusManager]")
{
FocusManager manager;
auto scope = make_rcp<FocusNode>();
auto node1 = make_rcp<FocusNode>();
auto node2 = make_rcp<FocusNode>();
scope->edgeBehavior(EdgeBehavior::stop);
manager.addChild(nullptr, scope);
manager.addChild(scope, node1);
manager.addChild(scope, node2);
manager.setFocus(node2);
manager.focusNext();
// Should stay on node2
CHECK(manager.primaryFocus() == node2);
}
TEST_CASE("FocusManager ancestor notification on focus", "[FocusManager]")
{
FocusManager manager;
MockFocusable grandparentFocusable, parentFocusable, childFocusable;
auto grandparent = make_rcp<FocusNode>(&grandparentFocusable);
auto parent = make_rcp<FocusNode>(&parentFocusable);
auto child = make_rcp<FocusNode>(&childFocusable);
manager.addChild(nullptr, grandparent);
manager.addChild(grandparent, parent);
manager.addChild(parent, child);
// Focus the leaf node
manager.setFocus(child);
// All ancestors should have received focused() callback
CHECK(childFocusable.focusedCount == 1);
CHECK(parentFocusable.focusedCount == 1);
CHECK(grandparentFocusable.focusedCount == 1);
// All nodes in the chain should have hasFocus flag
CHECK(child->hasFocus() == true);
CHECK(parent->hasFocus() == true);
CHECK(grandparent->hasFocus() == true);
}
TEST_CASE("FocusManager common ancestor optimization", "[FocusManager]")
{
FocusManager manager;
MockFocusable parentFocusable, child1Focusable, child2Focusable;
auto parent = make_rcp<FocusNode>(&parentFocusable);
auto child1 = make_rcp<FocusNode>(&child1Focusable);
auto child2 = make_rcp<FocusNode>(&child2Focusable);
manager.addChild(nullptr, parent);
manager.addChild(parent, child1);
manager.addChild(parent, child2);
// Focus first child
manager.setFocus(child1);
CHECK(parentFocusable.focusedCount == 1);
CHECK(child1Focusable.focusedCount == 1);
// Move focus to sibling - parent should NOT get re-notified
manager.setFocus(child2);
CHECK(child1Focusable.blurredCount == 1);
CHECK(child2Focusable.focusedCount == 1);
// Parent should not be blurred or re-focused
CHECK(parentFocusable.focusedCount == 1); // Still 1, not 2
CHECK(parentFocusable.blurredCount == 0);
// Parent still has focus (descendant focused)
CHECK(parent->hasFocus() == true);
}
TEST_CASE("FocusManager traversal focuses leaves only", "[FocusManager]")
{
FocusManager manager;
MockFocusable scopeFocusable, leaf1Focusable, leaf2Focusable;
auto scope = make_rcp<FocusNode>(&scopeFocusable);
auto leaf1 = make_rcp<FocusNode>(&leaf1Focusable);
auto leaf2 = make_rcp<FocusNode>(&leaf2Focusable);
manager.addChild(nullptr, scope);
manager.addChild(scope, leaf1);
manager.addChild(scope, leaf2);
// Start with no focus, focusNext should focus first leaf, not scope
manager.focusNext();
CHECK(manager.primaryFocus() == leaf1);
CHECK(manager.hasPrimaryFocus(scope) == false);
CHECK(scope->hasFocus() == true); // But scope has descendant focus
manager.focusNext();
CHECK(manager.primaryFocus() == leaf2);
}
TEST_CASE("FocusManager nested scopes focus deepest leaf", "[FocusManager]")
{
FocusManager manager;
auto scope1 = make_rcp<FocusNode>();
auto scope2 = make_rcp<FocusNode>();
auto leaf = make_rcp<FocusNode>();
manager.addChild(nullptr, scope1);
manager.addChild(scope1, scope2);
manager.addChild(scope2, leaf);
// Navigate should go directly to the deepest leaf
manager.focusNext();
CHECK(manager.primaryFocus() == leaf);
CHECK(scope1->hasFocus() == true);
CHECK(scope2->hasFocus() == true);
}
TEST_CASE("FocusManager edge behavior parentScope exits to parent",
"[FocusManager]")
{
FocusManager manager;
auto root = make_rcp<FocusNode>();
auto scope = make_rcp<FocusNode>();
auto inner1 = make_rcp<FocusNode>();
auto inner2 = make_rcp<FocusNode>();
auto outer = make_rcp<FocusNode>();
scope->edgeBehavior(EdgeBehavior::parentScope);
manager.addChild(nullptr, root);
manager.addChild(root, scope);
manager.addChild(scope, inner1);
manager.addChild(scope, inner2);
manager.addChild(root, outer);
// Focus last node in scope
manager.setFocus(inner2);
CHECK(manager.primaryFocus() == inner2);
// Navigate forward should exit scope and go to outer
manager.focusNext();
CHECK(manager.primaryFocus() == outer);
}
TEST_CASE("FocusManager clearFocus clears hasFocus flag chain",
"[FocusManager]")
{
FocusManager manager;
MockFocusable parentFocusable, childFocusable;
auto parent = make_rcp<FocusNode>(&parentFocusable);
auto child = make_rcp<FocusNode>(&childFocusable);
manager.addChild(nullptr, parent);
manager.addChild(parent, child);
manager.setFocus(child);
CHECK(parent->hasFocus() == true);
CHECK(child->hasFocus() == true);
manager.clearFocus();
// Both should be cleared
CHECK(parent->hasFocus() == false);
CHECK(child->hasFocus() == false);
// Both should have received blurred callback
CHECK(parentFocusable.blurredCount == 1);
CHECK(childFocusable.blurredCount == 1);
}
TEST_CASE("FocusManager removeChild clears manager reference", "[FocusManager]")
{
FocusManager manager;
auto node = make_rcp<FocusNode>();
manager.addChild(nullptr, node);
CHECK(node->manager() == &manager);
manager.removeChild(node);
CHECK(node->manager() == nullptr);
}
TEST_CASE("Freeing a FocusNode clears the parent pointer of a child that "
"outlives it",
"[FocusManager]")
{
FocusManager manager;
auto scope = make_rcp<FocusNode>(); // persistent host scope, held here
{
auto row = make_rcp<FocusNode>(); // transient list row
manager.addChild(nullptr, row);
manager.addChild(row, scope);
CHECK(scope->parent() == row.get());
// The list re-sync removes the row from the manager, then drops it.
manager.removeChild(row);
} // row FocusNode destroyed here; scope survives via the outer rcp
REQUIRE(scope->parent() == nullptr);
// Re-homing the survivor is now safe — no dereference of the freed row.
auto newParent = make_rcp<FocusNode>();
manager.addChild(nullptr, newParent);
manager.addChild(newParent, scope);
CHECK(scope->parent() == newParent.get());
CHECK(newParent->children().size() == 1);
}
TEST_CASE("FocusManager::addChild removes a migrating root from its previous "
"manager",
"[FocusManager]")
{
FocusManager internalManager;
FocusManager parentManager;
auto scope = make_rcp<FocusNode>();
internalManager.addChild(nullptr, scope);
CHECK(scope->manager() == &internalManager);
CHECK(internalManager.rootNodes().size() == 1);
// Migrate the scope to the parent manager (no FocusNode parent -> root).
parentManager.addChild(nullptr, scope);
CHECK(scope->manager() == &parentManager);
CHECK(parentManager.rootNodes().size() == 1);
// The internal manager must no longer reference the migrated scope.
CHECK(internalManager.rootNodes().empty());
}
TEST_CASE("A migrated focus scope survives destruction of its previous manager",
"[FocusManager]")
{
FocusManager parentManager;
auto scope = make_rcp<FocusNode>();
{
FocusManager internalManager;
internalManager.addChild(nullptr, scope);
parentManager.addChild(nullptr, scope); // migrate to parent
CHECK(scope->manager() == &parentManager);
} // internalManager destroyed here
// The scope still belongs to parentManager, not the destroyed one.
CHECK(scope->manager() == &parentManager);
if (scope->manager() != nullptr)
{
scope->manager()->removeChild(scope);
}
CHECK(parentManager.rootNodes().empty());
}
TEST_CASE("FocusManager traversal backward from first leaf exits scope",
"[FocusManager]")
{
FocusManager manager;
auto root = make_rcp<FocusNode>();
auto before = make_rcp<FocusNode>();
auto scope = make_rcp<FocusNode>();
auto inner = make_rcp<FocusNode>();
scope->edgeBehavior(EdgeBehavior::parentScope);
manager.addChild(nullptr, root);
manager.addChild(root, before);
manager.addChild(root, scope);
manager.addChild(scope, inner);
// Focus the inner node
manager.setFocus(inner);
// Navigate backward should exit scope and go to before
manager.focusPrevious();
CHECK(manager.primaryFocus() == before);
}
TEST_CASE("FocusManager closedLoop wraps backward", "[FocusManager]")
{
FocusManager manager;
auto scope = make_rcp<FocusNode>();
auto node1 = make_rcp<FocusNode>();
auto node2 = make_rcp<FocusNode>();
scope->edgeBehavior(EdgeBehavior::closedLoop);
manager.addChild(nullptr, scope);
manager.addChild(scope, node1);
manager.addChild(scope, node2);
manager.setFocus(node1);
manager.focusPrevious();
// Should wrap to last
CHECK(manager.primaryFocus() == node2);
}
TEST_CASE("FocusManager stop prevents backward traversal", "[FocusManager]")
{
FocusManager manager;
auto scope = make_rcp<FocusNode>();
auto node1 = make_rcp<FocusNode>();
auto node2 = make_rcp<FocusNode>();
scope->edgeBehavior(EdgeBehavior::stop);
manager.addChild(nullptr, scope);
manager.addChild(scope, node1);
manager.addChild(scope, node2);
manager.setFocus(node1);
manager.focusPrevious();
// Should stay on node1
CHECK(manager.primaryFocus() == node1);
}
TEST_CASE("StateMachineInstance hasFocusNodes ignores non-traversable scopes",
"[FocusManager]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
auto scope = make_rcp<FocusNode>();
scope->canFocus(false);
scope->canTraverse(false);
smi.focusManager()->addChild(nullptr, scope);
CHECK(smi.hasFocusNodes() == false);
}
TEST_CASE("StateMachineInstance hasFocusNodes sees leaves under a "
"transparent scope",
"[FocusManager]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
// Transparent structural scope as registered for a data-bound nested
// artboard host: unbacked (no focusable), canFocus/canTraverse/canTouch
// false. Traversal descends through it because it has no focusable.
auto scope = make_rcp<FocusNode>();
scope->canFocus(false);
scope->canTraverse(false);
scope->canTouch(false);
smi.focusManager()->addChild(nullptr, scope);
// Empty scope contributes no focus targets (e.g. a bindable artboard with
// no focus nodes).
CHECK(smi.hasFocusNodes() == false);
// Swapping in an artboard that has a focusable leaf must make the state
// machine report focus nodes, even though the leaf lives under the scope.
auto leaf = make_rcp<FocusNode>();
smi.focusManager()->addChild(scope, leaf);
CHECK(smi.hasFocusNodes() == true);
}
TEST_CASE("StateMachineInstance hasFocusNodes counts focus data that is "
"currently ineligible for traversal",
"[FocusManager]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
// hasFocusNodes gates one-time setup in high-level runtimes (attaching
// tab/shift+tab listeners in JS), so authored focus data must count even
// while it can't currently be focused: canFocus/canTraverse are
// data-bindable and collapse/visibility can change on any frame.
FocusData focusData;
// canFocus/canTraverse are now bits in the focusFlags bitmask; clear both
// (leave the rest) to make the node ineligible for traversal.
focusData.focusFlags(
focusData.focusFlags() &
~(FocusData::canFocusBitmask | FocusData::canTraverseBitmask));
smi.focusManager()->addChild(nullptr, focusData.focusNode());
CHECK(smi.hasFocusNodes() == true);
}
TEST_CASE("FocusManager traversal descends through a transparent scope "
"and keeps sibling order",
"[FocusManager]")
{
FocusManager manager;
auto leafA = make_rcp<FocusNode>();
auto scope = make_rcp<FocusNode>();
auto leafC = make_rcp<FocusNode>();
// scope mirrors a data-bound nested artboard host slot sitting between two
// sibling focus nodes: unbacked (no focusable) and not a focus target
// itself, but Tab descends through it to whatever artboard is swapped in.
scope->canFocus(false);
scope->canTraverse(false);
scope->canTouch(false);
manager.addChild(nullptr, leafA);
manager.addChild(nullptr, scope);
manager.addChild(nullptr, leafC);
// Empty scope is skipped: A -> C.
manager.focusNext();
CHECK(manager.primaryFocus() == leafA);
manager.focusNext();
CHECK(manager.primaryFocus() == leafC);
// Populate the scope (artboard swapped in). Its leaf occupies the scope's
// sibling slot, so traversal order becomes A -> B -> C.
manager.clearFocus();
auto leafB = make_rcp<FocusNode>();
manager.addChild(scope, leafB);
manager.focusNext();
CHECK(manager.primaryFocus() == leafA);
manager.focusNext();
CHECK(manager.primaryFocus() == leafB);
manager.focusNext();
CHECK(manager.primaryFocus() == leafC);
}
TEST_CASE("FocusManager drops focus when a leaf under a transparent scope "
"becomes hidden",
"[FocusManager]")
{
FocusManager manager;
// Unbacked scope: the shape of a data-bound nested artboard's scope node.
auto scope = make_rcp<FocusNode>();
scope->canFocus(false);
scope->canTraverse(false);
scope->canTouch(false);
// A focusable leaf inside it, like a swapped-in nested artboard's element.
MockFocusable leafFocusable;
auto leaf = make_rcp<FocusNode>(&leafFocusable);
manager.addChild(nullptr, scope);
manager.addChild(scope, leaf);
// Tab descends through the scope onto the nested leaf.
manager.focusNext();
REQUIRE(manager.primaryFocus() == leaf);
// Hide the nested content (its focusable reports ineligible). Focus must be
// dropped, not left stranded behind the scope.
leafFocusable.eligible = false;
manager.dropFocusIfFocusTargetHidden();
CHECK(manager.primaryFocus() == nullptr);
}
TEST_CASE("FocusManager rebuilding one scope's subtree preserves focus in a "
"sibling scope",
"[FocusManager]")
{
FocusManager manager;
// Two sibling transparent scopes, like two data-bound nested artboard
// hosts.
auto scopeA = make_rcp<FocusNode>();
scopeA->canFocus(false);
scopeA->canTraverse(false);
scopeA->canTouch(false);
auto scopeB = make_rcp<FocusNode>();
scopeB->canFocus(false);
scopeB->canTraverse(false);
scopeB->canTouch(false);
MockFocusable leafAFocusable, leafBFocusable;
auto leafA = make_rcp<FocusNode>(&leafAFocusable);
auto leafB = make_rcp<FocusNode>(&leafBFocusable);
manager.addChild(nullptr, scopeA);
manager.addChild(scopeA, leafA);
manager.addChild(nullptr, scopeB);
manager.addChild(scopeB, leafB);
// Focus the leaf inside scope A.
manager.setFocus(leafA);
REQUIRE(manager.primaryFocus() == leafA);
// Simulate swapping the artboard in sibling scope B: tear down B's current
// content and rebuild it with a new focusable leaf under the same scope.
// Focus held in the unrelated scope A must be untouched.
manager.removeChild(leafB);
MockFocusable leafB2Focusable;
auto leafB2 = make_rcp<FocusNode>(&leafB2Focusable);
manager.addChild(scopeB, leafB2);
CHECK(manager.primaryFocus() == leafA);
}
TEST_CASE("FocusActionTraversal perform advances focus with traversalKind next",
"[FocusActionTraversal]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
FocusManager* fm = smi.focusManager();
MockFocusable f1, f2;
auto node1 = make_rcp<FocusNode>(&f1);
auto node2 = make_rcp<FocusNode>(&f2);
fm->addChild(nullptr, node1);
fm->addChild(nullptr, node2);
fm->setFocus(node1);
FocusActionTraversal action;
action.traversalKind(0);
action.perform(&smi, ListenerInvocation::none());
CHECK(fm->primaryFocus() == node2);
}
TEST_CASE("FocusActionTraversal perform moves focus back with traversalKind "
"previous",
"[FocusActionTraversal]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
FocusManager* fm = smi.focusManager();
MockFocusable f1, f2;
auto node1 = make_rcp<FocusNode>(&f1);
auto node2 = make_rcp<FocusNode>(&f2);
fm->addChild(nullptr, node1);
fm->addChild(nullptr, node2);
fm->setFocus(node2);
FocusActionTraversal action;
action.traversalKind(1);
action.perform(&smi, ListenerInvocation::none());
CHECK(fm->primaryFocus() == node1);
}
TEST_CASE("FocusActionTraversal perform unknown traversalKind defaults to next",
"[FocusActionTraversal]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
FocusManager* fm = smi.focusManager();
MockFocusable f1, f2;
auto node1 = make_rcp<FocusNode>(&f1);
auto node2 = make_rcp<FocusNode>(&f2);
fm->addChild(nullptr, node1);
fm->addChild(nullptr, node2);
fm->setFocus(node1);
FocusActionTraversal action;
action.traversalKind(999);
action.perform(&smi, ListenerInvocation::none());
CHECK(fm->primaryFocus() == node2);
}
TEST_CASE(
"StateMachineInstance exposes hasFocusNodes, focusNext, focusPrevious from focusManager",
"[FocusManager]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
MockFocusable f1, f2;
auto node1 = make_rcp<FocusNode>(&f1);
auto node2 = make_rcp<FocusNode>(&f2);
CHECK(smi.hasFocusNodes() == false);
smi.focusManager()->addChild(nullptr, node1);
smi.focusManager()->addChild(nullptr, node2);
smi.focusManager()->setFocus(node1);
CHECK(smi.hasFocusNodes() == true);
CHECK(smi.focusNext() == true);
CHECK(smi.focusPrevious() == true);
}
TEST_CASE("FocusActionTraversal perform ignores null StateMachineInstance",
"[FocusActionTraversal]")
{
FocusActionTraversal action;
action.traversalKind(0);
action.perform(nullptr, ListenerInvocation::none());
}
// Mock Focusable that reports it accepts keyboard input.
class KeyboardAcceptingFocusable : public MockFocusable
{
public:
bool acceptsKeyboardInput() const override { return true; }
};
TEST_CASE("Focusable::acceptsKeyboardInput defaults to false", "[Focusable]")
{
MockFocusable f;
CHECK(f.acceptsKeyboardInput() == false);
KeyboardAcceptingFocusable kf;
CHECK(kf.acceptsKeyboardInput() == true);
}
TEST_CASE("StateMachineInstance::focusState reports no focus when nothing is "
"focused",
"[FocusState]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
auto state = smi.focusState();
CHECK(state.hasFocus == false);
CHECK(state.expectsKeyboardInput == false);
}
TEST_CASE("StateMachineInstance::focusState reports focused non-keyboard "
"focusable",
"[FocusState]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
MockFocusable f;
auto node = make_rcp<FocusNode>(&f);
smi.focusManager()->addChild(nullptr, node);
smi.focusManager()->setFocus(node);
auto state = smi.focusState();
CHECK(state.hasFocus == true);
CHECK(state.expectsKeyboardInput == false);
}
TEST_CASE("StateMachineInstance::focusState reports keyboard expectation when "
"focused focusable accepts keys",
"[FocusState]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
KeyboardAcceptingFocusable kf;
auto node = make_rcp<FocusNode>(&kf);
smi.focusManager()->addChild(nullptr, node);
smi.focusManager()->setFocus(node);
auto state = smi.focusState();
CHECK(state.hasFocus == true);
CHECK(state.expectsKeyboardInput == true);
}
TEST_CASE("StateMachineInstance::focusState clears when focus is cleared",
"[FocusState]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
KeyboardAcceptingFocusable kf;
auto node = make_rcp<FocusNode>(&kf);
smi.focusManager()->addChild(nullptr, node);
smi.focusManager()->setFocus(node);
REQUIRE(smi.focusState().hasFocus == true);
smi.focusManager()->clearFocus();
auto state = smi.focusState();
CHECK(state.hasFocus == false);
CHECK(state.expectsKeyboardInput == false);
}
TEST_CASE("StateMachineInstance::focusState tracks switches between focusables",
"[FocusState]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
MockFocusable plain;
KeyboardAcceptingFocusable kf;
auto plainNode = make_rcp<FocusNode>(&plain);
auto kfNode = make_rcp<FocusNode>(&kf);
smi.focusManager()->addChild(nullptr, plainNode);
smi.focusManager()->addChild(nullptr, kfNode);
smi.focusManager()->setFocus(plainNode);
{
auto state = smi.focusState();
CHECK(state.hasFocus == true);
CHECK(state.expectsKeyboardInput == false);
}
smi.focusManager()->setFocus(kfNode);
{
auto state = smi.focusState();
CHECK(state.hasFocus == true);
CHECK(state.expectsKeyboardInput == true);
}
smi.focusManager()->setFocus(plainNode);
{
auto state = smi.focusState();
CHECK(state.hasFocus == true);
CHECK(state.expectsKeyboardInput == false);
}
}
TEST_CASE("StateMachineInstance::focusState uses external focus manager when "
"set",
"[FocusState]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
FocusManager external;
KeyboardAcceptingFocusable kf;
auto node = make_rcp<FocusNode>(&kf);
external.addChild(nullptr, node);
external.setFocus(node);
// Before swapping, internal manager has nothing focused.
CHECK(smi.focusState().hasFocus == false);
smi.setExternalFocusManager(&external);
auto state = smi.focusState();
CHECK(state.hasFocus == true);
CHECK(state.expectsKeyboardInput == true);
}
TEST_CASE("StateMachineInstance::clearFocus clears internal focus manager",
"[FocusState]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
KeyboardAcceptingFocusable kf;
auto node = make_rcp<FocusNode>(&kf);
smi.focusManager()->addChild(nullptr, node);
smi.focusManager()->setFocus(node);
REQUIRE(smi.focusState().hasFocus == true);
smi.clearFocus();
auto state = smi.focusState();
CHECK(state.hasFocus == false);
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);
}
// =============================================================================
// FocusActionClear Tests
// =============================================================================
TEST_CASE("FocusActionClear perform clears the primary focus",
"[FocusActionClear]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
FocusManager* fm = smi.focusManager();
MockFocusable f1;
auto node1 = make_rcp<FocusNode>(&f1);
fm->addChild(nullptr, node1);
fm->setFocus(node1);
REQUIRE(fm->primaryFocus() == node1);
FocusActionClear action;
action.perform(&smi, ListenerInvocation::none());
CHECK(fm->primaryFocus() == nullptr);
}
TEST_CASE("FocusActionClear perform is a no-op when nothing is focused",
"[FocusActionClear]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
REQUIRE(smi.focusManager()->primaryFocus() == nullptr);
FocusActionClear action;
action.perform(&smi, ListenerInvocation::none());
CHECK(smi.focusManager()->primaryFocus() == nullptr);
}
TEST_CASE("FocusActionClear perform ignores null StateMachineInstance",
"[FocusActionClear]")
{
FocusActionClear action;
// Must not dereference the null instance.
action.perform(nullptr, ListenerInvocation::none());
}
// =============================================================================
// TransitionFocusCondition Tests
// =============================================================================
TEST_CASE("TransitionFocusCondition uses the reassigned core type key",
"[TransitionFocusCondition]")
{
// Locks in the collision fix: master's font PR claimed 1035, so this
// condition was reassigned to 1038. A regression here means a type-key
// clash on import/export.
// Copy into a local to avoid ODR-using the in-class static constant
// (which has no out-of-line definition) when binding it to Catch2's
// by-reference comparison expressions.
uint16_t typeKey = TransitionFocusConditionBase::typeKey;
CHECK(typeKey == 1038);
auto condition = std::make_unique<TransitionFocusCondition>();
CHECK(condition->coreType() == typeKey);
CHECK(condition->is<TransitionFocusCondition>());
}
TEST_CASE("TransitionFocusCondition evaluate returns false for a null "
"StateMachineInstance",
"[TransitionFocusCondition]")
{
// Heap allocation value-initializes the (comparator) members to null, so
// the guard clauses and destructor are well-defined even without import.
auto condition = std::make_unique<TransitionFocusCondition>();
CHECK(condition->evaluate(nullptr, nullptr) == false);
}
TEST_CASE("TransitionFocusCondition evaluate returns false when no target "
"comparator is configured",
"[TransitionFocusCondition]")
{
NoOpFactory factory;
Artboard artboard(&factory);
auto instance = artboard.instance();
StateMachine machine;
StateMachineInstance smi(&machine, instance.get());
auto condition = std::make_unique<TransitionFocusCondition>();
// With neither comparator set to a TransitionPropertyComponentComparator,
// there is no focus target to evaluate against, so the condition is false.
CHECK(condition->evaluate(&smi, nullptr) == false);
}
} // namespace rive
TEST_CASE("Swapping bindable artboard registers nested focus nodes for Tab",
"[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/bindable_focus_tree_swap.riv", &silver);
auto artboard = file->artboardDefault();
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
auto* focusManager = stateMachine->focusManager();
REQUIRE(focusManager != nullptr);
REQUIRE(stateMachine->hasFocusNodes() == true);
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
REQUIRE(focusManager->primaryFocus() != nullptr);
CHECK(stateMachine->focusNext() == false);
// There's only one focus node in the main artboard, go back to that last
// node
stateMachine->focusPrevious();
auto* artboardProp = vmi->propertyValue("bindedArt");
REQUIRE(artboardProp != nullptr);
REQUIRE(artboardProp->is<rive::ViewModelInstanceArtboard>());
auto* vmiArtboard = artboardProp->as<rive::ViewModelInstanceArtboard>();
// Has other focus nodes in this artboard
auto focusableSource = file->bindableArtboardNamed("Focusable");
REQUIRE(focusableSource != nullptr);
vmiArtboard->asset(focusableSource);
stateMachine->advanceAndApply(0.016f);
rive::NestedArtboard* focusableHost = nullptr;
for (auto* nestedHost : artboard->nestedArtboards())
{
auto* source = nestedHost->sourceArtboard();
if (source != nullptr && source->name() == "Focusable")
{
focusableHost = nestedHost;
break;
}
}
REQUIRE(focusableHost != nullptr);
auto* focusableInstance = focusableHost->artboardInstance(0);
REQUIRE(focusableInstance != nullptr);
CHECK(stateMachine->focusNext() == true);
CHECK(focusManager->primaryFocus() != nullptr);
CHECK(focusManager->primaryFocusImmediateArtboard() == focusableInstance);
}
TEST_CASE("Swapping a bindable nested artboard preserves focus held elsewhere",
"[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/bindable_focus_tree_swap.riv", &silver);
auto artboard = file->artboardDefault();
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
auto* focusManager = stateMachine->focusManager();
REQUIRE(focusManager != nullptr);
// Focus the main artboard's own focus node. Before the swap the bindable
// host is "Plain" (no focus nodes), so the main node is the only focusable.
focusManager->focusNext();
auto focused = focusManager->primaryFocus();
REQUIRE(focused != nullptr);
REQUIRE(focusManager->primaryFocusImmediateArtboard() == artboard.get());
// Swap the (unrelated) bindable nested artboard to one that HAS focus
// nodes.
auto* artboardProp = vmi->propertyValue("bindedArt");
REQUIRE(artboardProp != nullptr);
REQUIRE(artboardProp->is<rive::ViewModelInstanceArtboard>());
auto* vmiArtboard = artboardProp->as<rive::ViewModelInstanceArtboard>();
auto focusableSource = file->bindableArtboardNamed("Focusable");
REQUIRE(focusableSource != nullptr);
vmiArtboard->asset(focusableSource);
stateMachine->advanceAndApply(0.016f);
// Focus held on the main artboard must survive the unrelated nested swap:
// the swap only re-syncs the swapped host's subtree, not the whole tree.
CHECK(focusManager->primaryFocus() == focused);
CHECK(focusManager->primaryFocusImmediateArtboard() == artboard.get());
}
TEST_CASE("FocusManager skips collapsed nodes and fully transparent nodes",
"[FocusManager]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/focus_collapsing.riv", &silver);
auto artboard = file->artboardDefault();
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
auto focusManager = artboard->focusManager();
auto opacityProp =
vmi->propertyValue("opacity")->as<rive::ViewModelInstanceNumber>();
auto isMainLayout2VisibleProp = vmi->propertyValue("isMainLayout2Visible")
->as<rive::ViewModelInstanceBoolean>();
stateMachine->bindViewModelInstance(vmi);
// ===> Frame 0
auto renderer = silver.makeRenderer();
stateMachine->advanceAndApply(0.016f);
// ===> Frame 1
artboard->draw(renderer.get());
silver.addFrame();
focusManager->focusNext();
// The first focusable is now inside a data-bound nested artboard
REQUIRE(focusManager->primaryFocus() != nullptr);
REQUIRE(focusManager->primaryFocusImmediateArtboard() != nullptr);
REQUIRE(focusManager->primaryFocusImmediateArtboard() != artboard.get());
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
// ===> Frame 2
silver.addFrame();
// Tab next into the main artboard's own element — the one `opacity`
// controls.
focusManager->focusNext();
REQUIRE(focusManager->primaryFocus() != nullptr);
REQUIRE(focusManager->primaryFocusImmediateArtboard() == artboard.get());
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Hide that focused element; focus must be dropped.
opacityProp->propertyValue(0);
// First advance sets the opacity to 0
stateMachine->advanceAndApply(0.016f);
// Next frame the focus is dropped
stateMachine->advanceAndApply(0.016f);
REQUIRE(focusManager->primaryFocus() == nullptr);
artboard->draw(renderer.get());
// ===> Frame 3
silver.addFrame();
opacityProp->propertyValue(1);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
// ===> Frame 4
silver.addFrame();
focusManager->focusNext();
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
REQUIRE(focusManager->primaryFocus() != nullptr);
artboard->draw(renderer.get());
// ===> Frame 5
silver.addFrame();
isMainLayout2VisibleProp->propertyValue(false);
stateMachine->advanceAndApply(0.016f);
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
// ===> Frame 6
silver.addFrame();
// Toggles only between visible focused elements
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
// ===> Frame 7
silver.addFrame();
focusManager->focusNext();
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
// ===> Frame 8
silver.addFrame();
// Fully rotates over all nodes
isMainLayout2VisibleProp->propertyValue(true);
stateMachine->advanceAndApply(0.016f);
focusManager->focusNext();
artboard->draw(renderer.get());
// ===> Frame 9
silver.addFrame();
stateMachine->advanceAndApply(0.016f);
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
// ===> Frame 10
silver.addFrame();
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
focusManager->focusNext();
artboard->draw(renderer.get());
// ===> Frame 11
silver.addFrame();
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
// ===> Frame 12
silver.addFrame();
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
CHECK(silver.matches("focus_collapsing"));
}
TEST_CASE("Focused elements receive keyboard inputs", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/keyboard_listener.riv", &silver);
auto artboard = file->artboardDefault();
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
stateMachine->bindViewModelInstance(vmi);
auto renderer = silver.makeRenderer();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
auto focusManager = artboard->focusManager();
// Child index 5
focusManager->focusPrevious();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
focusManager->keyInput(rive::Key::space,
rive::KeyModifiers::none,
false,
false);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Child index 4
focusManager->focusPrevious();
// Child index 3
focusManager->focusPrevious();
// Child index 2
focusManager->focusPrevious();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
focusManager->keyInput(rive::Key::space,
rive::KeyModifiers::none,
false,
false);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Child index 1
focusManager->focusPrevious();
// Child index 0
focusManager->focusPrevious();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
focusManager->keyInput(rive::Key::space,
rive::KeyModifiers::none,
false,
false);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
focusManager->focusPrevious();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
focusManager->keyInput(rive::Key::space,
rive::KeyModifiers::none,
false,
false);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
CHECK(silver.matches("keyboard_listener"));
}
TEST_CASE("Keyboard inputs with different key combinations", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/keyboard_listener.riv", &silver);
auto artboard = file->artboardNamed("KeyboardInput");
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
int viewModelId = artboard.get()->viewModelId();
auto vmi = viewModelId == -1
? file->createViewModelInstance(artboard.get())
: file->createViewModelInstance(viewModelId, 0);
auto keyCountProp =
vmi->propertyValue("keyCount")->as<rive::ViewModelInstanceNumber>();
stateMachine->bindViewModelInstance(vmi);
auto renderer = silver.makeRenderer();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
auto focusManager = artboard->focusManager();
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Key "a" on phase down with no modifiers is captured
focusManager->keyInput(rive::Key::a, rive::KeyModifiers::none, true, false);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 1);
artboard->draw(renderer.get());
silver.addFrame();
// Key "a" on phase repeat with no modifiers is not captured
focusManager->keyInput(rive::Key::a, rive::KeyModifiers::none, true, true);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 1);
// Key "a" on phase up with no modifiers is captured
focusManager->keyInput(rive::Key::a,
rive::KeyModifiers::none,
false,
false);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 2);
// Key "a" on phase down with modifiers is not captured
focusManager->keyInput(rive::Key::a,
rive::KeyModifiers::shift,
true,
false);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 2);
// Key "e" on any phase is not captured
focusManager->keyInput(rive::Key::e,
rive::KeyModifiers::none,
false,
false);
focusManager->keyInput(rive::Key::e, rive::KeyModifiers::none, true, true);
focusManager->keyInput(rive::Key::e, rive::KeyModifiers::none, true, false);
CHECK(keyCountProp->propertyValue() == 2);
stateMachine->advanceAndApply(0.016f);
// Key "b" on phase down with no modifiers is NOT captured
focusManager->keyInput(rive::Key::b, rive::KeyModifiers::none, true, false);
// Key "b" on phase up with no modifiers is NOT captured
CHECK(keyCountProp->propertyValue() == 2);
stateMachine->advanceAndApply(0.016f);
focusManager->keyInput(rive::Key::b,
rive::KeyModifiers::none,
false,
false);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 3);
// Key "b" on phase repeat with no modifiers is captured
focusManager->keyInput(rive::Key::b, rive::KeyModifiers::none, true, true);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 4);
// Key "d" on phase down with no modifiers is not captured
focusManager->keyInput(rive::Key::d, rive::KeyModifiers::none, true, false);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 4);
// Key "d" on phase down with shift + command modifiers is captured
focusManager->keyInput(rive::Key::d,
rive::KeyModifiers::shift | rive::KeyModifiers::meta,
true,
false);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 5);
// Key "c" on phase down with shift + command modifiers is NOT captured
focusManager->keyInput(rive::Key::c,
rive::KeyModifiers::shift | rive::KeyModifiers::meta,
true,
false);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 5);
// Key "c" on phase down with shift modifiers is captured
focusManager->keyInput(rive::Key::c,
rive::KeyModifiers::shift,
true,
false);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 6);
// Key "x" on phase down with shift modifiers is NOT captured
focusManager->keyInput(rive::Key::x,
rive::KeyModifiers::shift,
true,
false);
stateMachine->advanceAndApply(0.016f);
CHECK(keyCountProp->propertyValue() == 6);
artboard->draw(renderer.get());
CHECK(silver.matches("keyboard_listener-KeyboardInput"));
}
TEST_CASE("Text input events are handled on focused nodes", "[silver]")
{
auto file = ReadRiveFile("assets/text_input_event.riv");
auto artboard = file->artboardDefault();
auto stateMachine = artboard->stateMachineAt(0);
auto vmi = file->createViewModelInstance(artboard.get());
auto isFocusedProp =
vmi->propertyValue("isFocused")->as<rive::ViewModelInstanceBoolean>();
auto hasKeyedProp =
vmi->propertyValue("hasKeyed")->as<rive::ViewModelInstanceBoolean>();
auto hasTextedProp =
vmi->propertyValue("hasTexted")->as<rive::ViewModelInstanceBoolean>();
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
auto focusManager = artboard->focusManager();
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
CHECK(isFocusedProp->propertyValue() == true);
CHECK(hasKeyedProp->propertyValue() == false);
CHECK(hasTextedProp->propertyValue() == false);
// Key "b" on phase down with no modifiers is NOT captured
focusManager->keyInput(rive::Key::b, rive::KeyModifiers::none, true, false);
stateMachine->advanceAndApply(0.016f);
CHECK(isFocusedProp->propertyValue() == true);
CHECK(hasKeyedProp->propertyValue() == false);
CHECK(hasTextedProp->propertyValue() == false);
// Text "b" on captured by text but not by key
focusManager->textInput("b");
stateMachine->advanceAndApply(0.016f);
CHECK(isFocusedProp->propertyValue() == true);
CHECK(hasKeyedProp->propertyValue() == false);
CHECK(hasTextedProp->propertyValue() == true);
// Key "a" on phase down with no modifiers is captured by key
focusManager->keyInput(rive::Key::a, rive::KeyModifiers::none, true, false);
stateMachine->advanceAndApply(0.016f);
CHECK(isFocusedProp->propertyValue() == true);
CHECK(hasKeyedProp->propertyValue() == true);
CHECK(hasTextedProp->propertyValue() == true);
}
TEST_CASE("Focus traversal listener actions", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/focus_traversal.riv", &silver);
auto artboard = file->artboardDefault();
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
auto renderer = silver.makeRenderer();
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// There are 2 rows of buttons
// Top row: Top / Right / Down / Left
// Bottom row: Prev / Next
// Click on Next
stateMachine->pointerDown(rive::Vec2D(180, 450));
stateMachine->pointerUp(rive::Vec2D(180, 450));
stateMachine->advanceAndApply(0.016f);
// Second advance to apply focus changes
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Click on Prev twice to reenter focus tree
stateMachine->pointerDown(rive::Vec2D(60, 450));
stateMachine->pointerUp(rive::Vec2D(60, 450));
stateMachine->advanceAndApply(0.016f);
stateMachine->pointerDown(rive::Vec2D(60, 450));
stateMachine->pointerUp(rive::Vec2D(60, 450));
stateMachine->advanceAndApply(0.016f);
// Second advance to apply focus changes
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Click on Up
stateMachine->pointerDown(rive::Vec2D(60, 350));
stateMachine->pointerUp(rive::Vec2D(60, 350));
stateMachine->advanceAndApply(0.016f);
// Second advance to apply focus changes
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Click on Left
stateMachine->pointerDown(rive::Vec2D(420, 350));
stateMachine->pointerUp(rive::Vec2D(420, 350));
stateMachine->advanceAndApply(0.016f);
// Second advance to apply focus changes
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Click on Down
stateMachine->pointerDown(rive::Vec2D(300, 350));
stateMachine->pointerUp(rive::Vec2D(300, 350));
stateMachine->advanceAndApply(0.016f);
// Second advance to apply focus changes
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Click on Right
stateMachine->pointerDown(rive::Vec2D(180, 350));
stateMachine->pointerUp(rive::Vec2D(180, 350));
stateMachine->advanceAndApply(0.016f);
// Second advance to apply focus changes
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
CHECK(silver.matches("focus_traversal"));
}
TEST_CASE("Focus traversal clears focus when it reaches edge of root scope",
"[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/focusable_element.riv", &silver);
auto artboard = file->artboardDefault();
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
auto vmi = file->createViewModelInstance(artboard.get());
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.1f);
auto renderer = silver.makeRenderer();
artboard->draw(renderer.get());
silver.addFrame();
stateMachine->focusManager()->focusNext();
stateMachine->advanceAndApply(0.1f);
artboard->draw(renderer.get());
silver.addFrame();
stateMachine->focusManager()->focusNext();
stateMachine->advanceAndApply(0.1f);
artboard->draw(renderer.get());
silver.addFrame();
stateMachine->focusManager()->focusNext();
stateMachine->advanceAndApply(0.1f);
artboard->draw(renderer.get());
silver.addFrame();
stateMachine->focusManager()->focusNext();
stateMachine->advanceAndApply(0.1f);
artboard->draw(renderer.get());
silver.addFrame();
stateMachine->focusManager()->focusNext();
stateMachine->advanceAndApply(0.1f);
artboard->draw(renderer.get());
silver.addFrame();
stateMachine->focusManager()->focusNext();
stateMachine->advanceAndApply(0.1f);
artboard->draw(renderer.get());
silver.addFrame();
stateMachine->focusManager()->focusNext();
stateMachine->advanceAndApply(0.1f);
artboard->draw(renderer.get());
CHECK(silver.matches("focusable_element"));
}
TEST_CASE("ArtboardComponentList list scope is registered on shared "
"FocusManager",
"[FocusManager][list]")
{
auto file = ReadRiveFile("assets/component_list_1.riv");
auto artboard = file->artboard("Main")->instance();
REQUIRE(artboard != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
artboard->bindViewModelInstance(vmi);
auto sm = artboard->stateMachineAt(0);
REQUIRE(sm != nullptr);
artboard->advance(0.0f);
auto* list = artboard->find<rive::ArtboardComponentList>("List");
REQUIRE(list != nullptr);
auto* fm = artboard->focusManager();
REQUIRE(fm != nullptr);
artboard->buildFocusTree(artboard->focusManager(), nullptr);
auto scope = list->listScopeFocusNode();
REQUIRE(scope != nullptr);
CHECK(scope->manager() == fm);
CHECK(scope->name() == "ArtboardComponentListScope");
// Transparent structural scope: not a focus target itself; traversal
// descends through it (focusNodeTraversable) to reach item focusables.
CHECK(scope->canFocus() == false);
CHECK(scope->canTraverse() == false);
CHECK(scope->focusable() == nullptr);
}
TEST_CASE("List under Node: when parent has a direct FocusData, "
"findClosestFocusNode from list matches that node",
"[FocusManager][list]")
{
// buildFocusTreeVisit pass-1: at most one direct child FocusData per
// container; if present, its focusNode is the scope for siblings (e.g. the
// list host). The walk-based fallback from the old findClosest for the
// no-direct-FocusData case is not used by the focus build anymore.
auto file = ReadRiveFile("assets/component_list_1.riv");
auto artboard = file->artboard("Main")->instance();
REQUIRE(artboard != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
artboard->bindViewModelInstance(vmi);
auto sm = artboard->stateMachineAt(0);
REQUIRE(sm != nullptr);
artboard->advance(0.0f);
auto* list = artboard->find<rive::ArtboardComponentList>("List");
REQUIRE(list != nullptr);
auto* p = list->parent();
REQUIRE(p != nullptr);
REQUIRE(p->is<rive::Node>());
rive::rcp<rive::FocusNode> fromFirstDirectFd;
for (auto* ch : p->as<rive::Node>()->children())
{
if (ch != nullptr && ch->is<rive::FocusData>())
{
fromFirstDirectFd = ch->as<rive::FocusData>()->focusNode();
break;
}
}
if (fromFirstDirectFd != nullptr)
{
CHECK(rive::FocusData::findClosestFocusNode(list) == fromFirstDirectFd);
}
}
TEST_CASE("Focus is correctly built and updated for lists", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/list_focus_order.riv", &silver);
auto artboard = file->artboardDefault();
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
auto focusManager = stateMachine->focusManager();
auto vmi = file->createDefaultViewModelInstance(artboard.get());
auto stageProcessedProp = vmi->propertyValue("stageProcessed")
->as<rive::ViewModelInstanceBoolean>();
auto stageCountProp =
vmi->propertyValue("stageCount")->as<rive::ViewModelInstanceNumber>();
auto renderer = silver.makeRenderer();
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Focuses on first element of tree
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Focuses on last element of list
focusManager->focusNext();
focusManager->focusNext();
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Inserts one element at end of list
stageProcessedProp->propertyValue(false);
stageCountProp->propertyValue(1);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Focus is on that new element
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Focused elements is moved in the list and keeps focus
stageProcessedProp->propertyValue(false);
stageCountProp->propertyValue(2);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Focusing on the next element correctly focuses on the next element on the
// list
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Removing the focused element from the list, clears the focus
stageProcessedProp->propertyValue(false);
stageCountProp->propertyValue(3);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
// Focuses back on first element of tree
focusManager->focusNext();
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
CHECK(silver.matches("list_focus_order"));
}
TEST_CASE("Focus based transitions work", "[silver]")
{
rive::SerializingFactory silver;
auto file = ReadRiveFile("assets/focus_test.riv", &silver);
auto artboard = file->artboardDefault();
REQUIRE(artboard != nullptr);
silver.frameSize(artboard->width(), artboard->height());
auto stateMachine = artboard->stateMachineAt(0);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
auto renderer = silver.makeRenderer();
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
stateMachine->pointerDown(rive::Vec2D(55.0, 65.0));
stateMachine->pointerUp(rive::Vec2D(55.0, 65.0));
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
silver.addFrame();
stateMachine->pointerDown(rive::Vec2D(442.0, 65.0));
stateMachine->pointerUp(rive::Vec2D(442.0, 65.0));
stateMachine->advanceAndApply(0.016f);
artboard->draw(renderer.get());
CHECK(silver.matches("focus_test"));
}
TEST_CASE("List item focus tree stays under its row when the item's state "
"machine is (re)wired during the focus sync",
"[FocusManager][list]")
{
// Regression for the syncListRowNodesWithList ordering bug: each list
// item's state machine must be wired to the shared FocusManager BEFORE the
// item's focus tree is (re)built under its row. setExternalFocusManager
// rebuilds the item's focus tree at the manager ROOT as a side effect, so
// if it runs after the build-under-row it clobbers the row placement and
// the item's focus nodes end up detached from the list scope (at the
// manager root).
//
// The natural build path happens to wire the manager first (via
// linkStateMachineToArtboard, whose setExternalFocusManager runs before the
// row sync), so the in-loop call is normally skipped by the
// `smi->focusManager() != fm` guard. Force the mismatch to exercise the
// ordering directly.
auto file = ReadRiveFile("assets/list_focus_order.riv");
auto artboard = file->artboardDefault();
REQUIRE(artboard != nullptr);
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
auto* fm = stateMachine->focusManager();
REQUIRE(fm != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
REQUIRE(artboard->artboardComponentLists().size() == 1);
auto* list = artboard->artboardComponentLists()[0];
REQUIRE(list != nullptr);
const int itemCount = static_cast<int>(list->artboardCount());
REQUIRE(itemCount > 0);
// A row node with children means the item's focus subtree is parented under
// it (inside the list scope) — the invariant the bug breaks.
auto rowForItem = [&](int i) -> rive::FocusNode* {
auto scope = list->listScopeFocusNode();
if (scope == nullptr || i >= static_cast<int>(scope->children().size()))
{
return nullptr;
}
return scope->children()[static_cast<size_t>(i)].get();
};
// Pick a list item that (after the normal build) has focus content placed
// under its row AND owns a state machine — the only case where the in-loop
// setExternalFocusManager fires.
int targetIndex = -1;
for (int i = 0; i < itemCount; i++)
{
rive::FocusNode* row = rowForItem(i);
if (row != nullptr && !row->children().empty() &&
list->stateMachineInstance(i) != nullptr)
{
targetIndex = i;
break;
}
}
REQUIRE(targetIndex != -1);
// Force the mismatch: drop the item's shared-manager wiring so the next
// focus sync must call setExternalFocusManager(fm) again — the exact call
// whose manager-root rebuild would clobber the row placement if it ran
// after the build-under-row.
list->stateMachineInstance(targetIndex)->setExternalFocusManager(nullptr);
CHECK(list->stateMachineInstance(targetIndex)->focusManager() != fm);
// Re-run the parent focus build; this recreates the list scope/rows and
// re-syncs each item under its row.
artboard->cleanupFocusTree();
artboard->buildFocusTree(fm, nullptr);
// With the fix (wire first, place last) the item's focus subtree is
// parented under its row inside the list scope. With the bug it was rebuilt
// at the manager root, leaving the row empty.
rive::FocusNode* targetRow = rowForItem(targetIndex);
REQUIRE(targetRow != nullptr);
CHECK(targetRow->manager() == fm);
CHECK_FALSE(targetRow->children().empty());
CHECK(list->stateMachineInstance(targetIndex)->focusManager() == fm);
}
TEST_CASE("Swappable artboard slot keeps its place in tab order",
"[FocusManager]")
{
// File: https://editor.uat.rive.app/file/untitled/36028
auto file = ReadRiveFile("assets/swappable_artboards_focus.riv");
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
auto* focusManager = stateMachine->focusManager();
REQUIRE(focusManager != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
stateMachine->advanceAndApply(0.016f);
// Only the data-bound slot is flagged as swappable; static nested
// artboards get no placeholder scope regardless of whether their artboard
// contains focusables.
rive::NestedArtboard* slotHost = nullptr;
for (auto* host : artboard->nestedArtboards())
{
auto* source = host->sourceArtboard();
REQUIRE(source != nullptr);
if (source->name() == "Swappable1" || source->name() == "Swappable2")
{
CHECK(host->isArtboardDataBound() == true);
slotHost = host;
}
else
{
CHECK(host->isArtboardDataBound() == false);
}
}
REQUIRE(slotHost != nullptr);
CHECK(stateMachine->hasFocusNodes() == true);
// The artboard owning the currently focused element.
auto focusedArtboardName = [&]() -> std::string {
auto* ab = focusManager->primaryFocusImmediateArtboard();
return ab != nullptr ? ab->name() : "<none>";
};
// Initial tab order follows the Main hierarchy: Rectangle (Main) -> slot
// (Swappable1) -> StaticNestWithFocusable. StaticNestWithoutFocusable
// contributes nothing.
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Main");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Swappable1");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "StaticNestWithFocusable");
// Edge of the root scope clears focus.
CHECK(stateMachine->focusNext() == false);
CHECK(focusManager->primaryFocus() == nullptr);
// Swap the slot to an artboard with no focusables: the slot contributes
// no focus stop and the rest of the order is untouched.
auto* artboardProp = vmi->propertyValue("artboardProp");
REQUIRE(artboardProp != nullptr);
REQUIRE(artboardProp->is<rive::ViewModelInstanceArtboard>());
auto* vmiArtboard = artboardProp->as<rive::ViewModelInstanceArtboard>();
auto swappable2 = file->bindableArtboardNamed("Swappable2");
REQUIRE(swappable2 != nullptr);
vmiArtboard->asset(swappable2);
stateMachine->advanceAndApply(0.016f);
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Main");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "StaticNestWithFocusable");
CHECK(stateMachine->focusNext() == false);
// Focus the Main rectangle, then swap back to the focusable artboard:
// focus held elsewhere survives the (unrelated) swap...
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Main");
auto heldFocus = focusManager->primaryFocus();
auto swappable1 = file->bindableArtboardNamed("Swappable1");
REQUIRE(swappable1 != nullptr);
vmiArtboard->asset(swappable1);
stateMachine->advanceAndApply(0.016f);
CHECK(focusManager->primaryFocus() == heldFocus);
CHECK(focusedArtboardName() == "Main");
// ...and the swapped-in focusable takes the slot's place in the middle of
// the tab order (its hierarchy position), not the end.
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Swappable1");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "StaticNestWithFocusable");
CHECK(stateMachine->focusNext() == false);
}
TEST_CASE("Repeat focus-tree build keeps focus inside an untouched nested "
"artboard",
"[FocusManager]")
{
// #4 regression: a second full buildFocusTree pass over an already-wired
// tree (same manager) must not tear down and rebuild nested artboards that
// did not change — doing so blurs focus resting inside them. Only the
// non-destructive scope placement should run on the repeat pass.
auto file = ReadRiveFile("assets/swappable_artboards_focus.riv");
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
auto* focusManager = stateMachine->focusManager();
REQUIRE(focusManager != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
stateMachine->advanceAndApply(0.016f);
auto focusedArtboardName = [&]() -> std::string {
auto* ab = focusManager->primaryFocusImmediateArtboard();
return ab != nullptr ? ab->name() : "<none>";
};
// Tab into the focusable that lives inside the STATIC nested artboard.
// Order (established by the sibling test): Main -> Swappable1 ->
// StaticNestWithFocusable.
CHECK(stateMachine->focusNext() == true);
CHECK(stateMachine->focusNext() == true);
CHECK(stateMachine->focusNext() == true);
REQUIRE(focusedArtboardName() == "StaticNestWithFocusable");
auto heldFocus = focusManager->primaryFocus();
REQUIRE(heldFocus != nullptr);
// Repeat the full build pass with the SAME manager (mirrors the host's
// documented two-phase build, or any later focus-tree re-wire). Nothing
// about the static nested artboard changed, so the focus resting inside it
// must survive rather than being blurred by a needless rebuild.
artboard->buildFocusTree(focusManager, nullptr);
CHECK(focusManager->primaryFocus() == heldFocus);
CHECK(focusedArtboardName() == "StaticNestWithFocusable");
}
TEST_CASE("Cross-file swaps keep slot order and share the focus manager",
"[FocusManager]")
{
// The slot's host, bind, and scope all live in the main file; the
// swapped-in artboard may come from a different .riv. Loading the asset
// twice yields two independent Files, so pulling bindable artboards from
// the second File exercises the cross-file path.
auto file = ReadRiveFile("assets/swappable_artboards_focus.riv");
auto otherFile = ReadRiveFile("assets/swappable_artboards_focus.riv");
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
auto* focusManager = stateMachine->focusManager();
REQUIRE(focusManager != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
auto focusedArtboard = [&]() -> rive::Artboard* {
return focusManager->primaryFocusImmediateArtboard();
};
auto focusedArtboardName = [&]() -> std::string {
auto* ab = focusedArtboard();
return ab != nullptr ? ab->name() : "<none>";
};
// The slot host's bound state machine (created by the latest swap).
auto slotBoundStateMachine = [&]() -> rive::StateMachineInstance* {
for (auto* host : artboard->nestedArtboards())
{
if (!host->isArtboardDataBound())
{
continue;
}
for (auto* animation : host->nestedAnimations())
{
if (animation->is<rive::NestedStateMachine>())
{
return animation->as<rive::NestedStateMachine>()
->stateMachineInstance();
}
}
}
return nullptr;
};
auto* artboardProp = vmi->propertyValue("artboardProp");
REQUIRE(artboardProp != nullptr);
REQUIRE(artboardProp->is<rive::ViewModelInstanceArtboard>());
auto* vmiArtboard = artboardProp->as<rive::ViewModelInstanceArtboard>();
// Swap in a LEAF artboard (one focusable, no nested hosts) from the
// other file.
auto foreignSwappable = otherFile->bindableArtboardNamed("Swappable1");
REQUIRE(foreignSwappable != nullptr);
vmiArtboard->asset(foreignSwappable);
stateMachine->advanceAndApply(0.016f);
// The foreign artboard's focus node sits at the slot's hierarchy
// position, exactly like a same-file swap.
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Main");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Swappable1");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "StaticNestWithFocusable");
CHECK(stateMachine->focusNext() == false);
// The swapped-in artboard's own state machine must share the parent
// FocusManager, so its focus/keyboard listener groups act on the same
// focus state that Tab traversal uses.
auto* leafSmi = slotBoundStateMachine();
REQUIRE(leafSmi != nullptr);
CHECK(leafSmi->focusManager() == focusManager);
}
TEST_CASE("Unresolvable artboard swap leaves focus and tab order untouched",
"[FocusManager]")
{
auto file = ReadRiveFile("assets/swappable_artboards_focus.riv");
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
auto* focusManager = stateMachine->focusManager();
REQUIRE(focusManager != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
stateMachine->advanceAndApply(0.016f);
auto focusedArtboardName = [&]() -> std::string {
auto* ab = focusManager->primaryFocusImmediateArtboard();
return ab != nullptr ? ab->name() : "<none>";
};
// Default order (per the sibling test): Main -> Swappable1 ->
// StaticNestWithFocusable. Rest focus on Main's Rectangle and hold the rcp.
CHECK(stateMachine->focusNext() == true);
REQUIRE(focusedArtboardName() == "Main");
auto heldFocus = focusManager->primaryFocus();
REQUIRE(heldFocus != nullptr);
// Drive the slot's VM artboard property into the UNRESOLVABLE state: no
// bindable asset and a bogus (non -1) id that matches no artboard. This is
// distinct from an explicit clear (asset null AND propertyValue == -1), so
// updateArtboard must return early and leave the on-screen slot alone.
auto* artboardProp = vmi->propertyValue("artboardProp");
REQUIRE(artboardProp != nullptr);
REQUIRE(artboardProp->is<rive::ViewModelInstanceArtboard>());
auto* vmiArtboard = artboardProp->as<rive::ViewModelInstanceArtboard>();
vmiArtboard->propertyValue(9999u);
REQUIRE(vmiArtboard->asset() == nullptr);
REQUIRE(vmiArtboard->propertyValue() != static_cast<uint32_t>(-1));
stateMachine->advanceAndApply(0.016f);
// Focus held on Main survives the failed swap...
CHECK(focusManager->primaryFocus() == heldFocus);
CHECK(focusedArtboardName() == "Main");
// ...and the outgoing Swappable1 kept its focus nodes, so the full tab
// order is unchanged: Main -> Swappable1 -> StaticNestWithFocusable.
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Swappable1");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "StaticNestWithFocusable");
CHECK(stateMachine->focusNext() == false);
}
TEST_CASE("Initially-empty bindable slot keeps its authored tab position on "
"first swap",
"[FocusManager]")
{
auto file = ReadRiveFile("assets/swappable_artboards_focus.riv");
auto artboard = file->artboardNamed("Main");
REQUIRE(artboard != nullptr);
auto stateMachine = artboard->stateMachineAt(0);
REQUIRE(stateMachine != nullptr);
auto* focusManager = stateMachine->focusManager();
REQUIRE(focusManager != nullptr);
auto vmi = file->createDefaultViewModelInstance(artboard.get());
REQUIRE(vmi != nullptr);
// Clear the slot to explicit null (asset null, propertyValue -1) BEFORE the
// first advance, so the slot is empty when the focus tree is first built.
auto* artboardProp = vmi->propertyValue("artboardProp");
REQUIRE(artboardProp != nullptr);
REQUIRE(artboardProp->is<rive::ViewModelInstanceArtboard>());
auto* vmiArtboard = artboardProp->as<rive::ViewModelInstanceArtboard>();
vmiArtboard->asset(nullptr);
stateMachine->bindViewModelInstance(vmi);
stateMachine->advanceAndApply(0.016f);
stateMachine->advanceAndApply(0.016f);
auto focusedArtboardName = [&]() -> std::string {
auto* ab = focusManager->primaryFocusImmediateArtboard();
return ab != nullptr ? ab->name() : "<none>";
};
// The empty slot's scope holds its place but offers no focus stop, so the
// order skips it: Main -> StaticNestWithFocusable.
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Main");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "StaticNestWithFocusable");
CHECK(stateMachine->focusNext() == false);
// Swap Swappable1 in for the first time: it must build under the scope the
// empty-slot build pass already placed, entering the MIDDLE of the tab
// order (Main -> Swappable1 -> StaticNestWithFocusable), not the end.
auto swappable1 = file->bindableArtboardNamed("Swappable1");
REQUIRE(swappable1 != nullptr);
vmiArtboard->asset(swappable1);
stateMachine->advanceAndApply(0.016f);
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Main");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "Swappable1");
CHECK(stateMachine->focusNext() == true);
CHECK(focusedArtboardName() == "StaticNestWithFocusable");
CHECK(stateMachine->focusNext() == false);
}