| #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); |
| } |