| #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_batch.hpp" | |
| #include "rive/input/gamepad_snapshot.hpp" | |
| #include "rive/input/standard_gamepad.hpp" | |
| #include <cstring> | |
| namespace rive | |
| { | |
| namespace | |
| { | |
| // A root artboard normally gets its FocusManager from File::instanceArtboard. | |
| // Tests that build an Artboard directly and instance it skip that path, so | |
| // give the instance a manager explicitly — otherwise focusManager() is null. | |
| std::unique_ptr<ArtboardInstance> instanceWithFocus(Artboard& artboard) | |
| { | |
| auto instance = artboard.instance(); | |
| instance->ensureFocusManager(); | |
| return instance; | |
| } | |
| // Minimal builder for the little-endian gamepad batch wire format documented | |
| // in `gamepad_batch.cpp`, enough to connect a pad and press one button. | |
| // gamepad_test.cpp has a fuller version for the parsing tests. | |
| struct GamepadWire | |
| { | |
| std::vector<uint8_t> buf; | |
| void u8(uint8_t v) { buf.push_back(v); } | |
| void u32(uint32_t v) | |
| { | |
| buf.push_back(static_cast<uint8_t>(v)); | |
| buf.push_back(static_cast<uint8_t>(v >> 8)); | |
| buf.push_back(static_cast<uint8_t>(v >> 16)); | |
| buf.push_back(static_cast<uint8_t>(v >> 24)); | |
| } | |
| void f32(float v) | |
| { | |
| uint32_t bits; | |
| std::memcpy(&bits, &v, sizeof(bits)); | |
| u32(bits); | |
| } | |
| GamepadWire() { u32(kGamepadBatchWireVersion); } | |
| // A standard-mapped pad: 17 buttons, 4 axes, all at rest. | |
| void connected(int32_t deviceId) | |
| { | |
| u8(static_cast<uint8_t>(GamepadRecordType::connected)); | |
| u32(static_cast<uint32_t>(deviceId)); | |
| u8(0); // mapping: standard | |
| u8(17); // buttons | |
| u8(4); // axes | |
| u8(0); // padding to align the float arrays | |
| for (int i = 0; i < 17 + 4; i++) | |
| { | |
| f32(0.f); | |
| } | |
| } | |
| void button(int32_t deviceId, StandardGamepadButton index, float value) | |
| { | |
| u8(static_cast<uint8_t>(GamepadRecordType::update)); | |
| u32(static_cast<uint32_t>(deviceId)); | |
| u8(1); // nChanges | |
| u8(static_cast<uint8_t>(GamepadInputChangeKind::button)); | |
| u8(static_cast<uint8_t>(index)); | |
| f32(value); | |
| } | |
| }; | |
| } // namespace | |
| // 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; } | |
| // Lets a test say which artboard tree a node belongs to, for the | |
| // root-scoped focus calls. Null (the default) means "not attributable to | |
| // any root", which is how a host-created FocusNode reads. | |
| Artboard* artboard = nullptr; | |
| Artboard* focusableArtboard() const override { return artboard; } | |
| }; | |
| // ============================================================================= | |
| // 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("a node that loses its Focusable stops being a focus stop", | |
| "[FocusNode]") | |
| { | |
| // ~FocusData clears its node's Focusable. If that node is still in the | |
| // tree (a detach that was never paired with a re-add, a manager torn down | |
| // around it), nothing is left that could report it collapsed or hidden — | |
| // so it must not stay eligible, or it becomes a focus stop that no | |
| // visibility change can ever remove. | |
| FocusManager manager; | |
| MockFocusable focusable; | |
| auto live = make_rcp<FocusNode>(&focusable); | |
| auto defunct = make_rcp<FocusNode>(&focusable); | |
| manager.addChild(nullptr, live); | |
| manager.addChild(nullptr, defunct); | |
| // Both are reachable while backed. | |
| CHECK(manager.getTraversableNodes(nullptr).size() == 2); | |
| defunct->clearFocusable(); | |
| CHECK(defunct->hadFocusable()); | |
| auto traversable = manager.getTraversableNodes(nullptr); | |
| REQUIRE(traversable.size() == 1); | |
| CHECK(traversable[0] == live.get()); | |
| // It can't be focused programmatically either. | |
| manager.setFocus(defunct); | |
| CHECK(manager.primaryFocusPtr() != defunct.get()); | |
| } | |
| TEST_CASE("a node that never had a Focusable stays focusable", "[FocusNode]") | |
| { | |
| // Hosts create bare FocusNodes as focus targets through this API; they | |
| // have no Focusable to consult and must keep working. Only a node that | |
| // *lost* its backing is treated as defunct. | |
| FocusManager manager; | |
| auto external = make_rcp<FocusNode>(); | |
| CHECK_FALSE(external->hadFocusable()); | |
| manager.addChild(nullptr, external); | |
| auto traversable = manager.getTraversableNodes(nullptr); | |
| REQUIRE(traversable.size() == 1); | |
| CHECK(traversable[0] == external.get()); | |
| manager.setFocus(external); | |
| CHECK(manager.primaryFocusPtr() == external.get()); | |
| } | |
| TEST_CASE("traversal still descends through a defunct node to live children", | |
| "[FocusNode]") | |
| { | |
| // A defunct node stops being a stop, but its children may still be live — | |
| // traversal must keep reaching them rather than skipping the subtree. | |
| FocusManager manager; | |
| MockFocusable focusable; | |
| auto defunctScope = make_rcp<FocusNode>(&focusable); | |
| auto liveChild = make_rcp<FocusNode>(&focusable); | |
| manager.addChild(nullptr, defunctScope); | |
| manager.addChild(defunctScope, liveChild); | |
| defunctScope->clearFocusable(); | |
| // Tab traversal walks through the defunct scope and lands on the child. | |
| CHECK(manager.focusNext()); | |
| CHECK(manager.primaryFocusPtr() == liveChild.get()); | |
| // Note: setFocus() aimed directly at the defunct node stays a no-op. Its | |
| // descend-to-first-leaf step is gated on the requested target being | |
| // eligible, so an ineligible target is rejected outright — the same as any | |
| // other collapsed or hidden target. | |
| manager.clearFocus(); | |
| manager.setFocus(defunctScope); | |
| CHECK(manager.primaryFocusPtr() == 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("adding a node claims its whole subtree for the manager", | |
| "[FocusManager]") | |
| { | |
| // detachChild clears the manager pointer across the entire subtree, so | |
| // addChild has to restore it across the entire subtree too. A host that | |
| // rebuilds by detaching and re-adding a set of nodes (the Dart editor does | |
| // exactly this) never touches the descendants underneath them; if those | |
| // kept a null manager, their FocusData could no longer remove them from | |
| // the tree when it died, stranding them here forever. | |
| FocusManager manager; | |
| MockFocusable focusable; | |
| auto parent = make_rcp<FocusNode>(&focusable); | |
| auto child = make_rcp<FocusNode>(&focusable); | |
| auto grandchild = make_rcp<FocusNode>(&focusable); | |
| manager.addChild(nullptr, parent); | |
| manager.addChild(parent, child); | |
| manager.addChild(child, grandchild); | |
| CHECK(parent->manager() == &manager); | |
| CHECK(child->manager() == &manager); | |
| CHECK(grandchild->manager() == &manager); | |
| // Detach only the top of the subtree — descendants come along untouched. | |
| manager.detachChild(parent); | |
| CHECK(parent->manager() == nullptr); | |
| CHECK(child->manager() == nullptr); | |
| CHECK(grandchild->manager() == nullptr); | |
| CHECK(parent->children().size() == 1); | |
| // Re-adding just the top has to re-claim everything beneath it. | |
| manager.addChild(nullptr, parent); | |
| CHECK(parent->manager() == &manager); | |
| CHECK(child->manager() == &manager); | |
| CHECK(grandchild->manager() == &manager); | |
| } | |
| TEST_CASE("a rebuild that misses a node still lets its FocusData clean up", | |
| "[FocusManager]") | |
| { | |
| // End-to-end shape of the editor's rebuildFocusHierarchy: collect a subset | |
| // of the tree, detach each collected node, re-add it. A node sitting under | |
| // a collected node that the collection itself missed used to come out of | |
| // that cycle with no manager pointer — and then its FocusData could never | |
| // remove it, so it survived as an unbacked node that | |
| // focusNodeEligibleForFocus treated as eligible: a focus stop nothing | |
| // could take out. | |
| FocusManager manager; | |
| MockFocusable focusable; | |
| auto root = make_rcp<FocusNode>(&focusable); | |
| manager.addChild(nullptr, root); | |
| { | |
| FocusData missedByTheRebuild; | |
| auto missedNode = missedByTheRebuild.focusNode(); | |
| manager.addChild(root, missedNode); | |
| // The rebuild touches only `root`; `missedNode` is never collected. | |
| manager.detachChild(root); | |
| manager.addChild(nullptr, root); | |
| CHECK(missedNode->manager() == &manager); | |
| CHECK(root->children().size() == 1); | |
| } | |
| // The FocusData is gone, and so is its node — no unbacked leftover. | |
| CHECK(root->children().empty()); | |
| // And even if one did survive, it must not be a focus stop. | |
| CHECK(manager.getTraversableNodes(root.get()).empty()); | |
| } | |
| TEST_CASE("a dying FocusData removes its node via an ancestor's manager", | |
| "[FocusData]") | |
| { | |
| // A node can sit in a live tree while holding no manager pointer of its | |
| // own. ~FocusData still has to take it out: left behind, it would keep a | |
| // cleared Focusable and nothing could ever report it hidden. | |
| FocusManager manager; | |
| MockFocusable focusable; | |
| auto scope = make_rcp<FocusNode>(&focusable); | |
| manager.addChild(nullptr, scope); | |
| REQUIRE(scope->manager() == &manager); | |
| { | |
| FocusData data; | |
| auto node = data.focusNode(); | |
| // Parent it through FocusNode directly, which doesn't hand out a | |
| // manager pointer — the same state a detach/re-add cycle used to | |
| // leave descendants in. | |
| scope->addChild(node); | |
| REQUIRE(node->manager() == nullptr); | |
| REQUIRE(scope->children().size() == 1); | |
| manager.setFocus(node); | |
| REQUIRE(manager.primaryFocusPtr() == node.get()); | |
| } | |
| // Removed through the nearest registered ancestor, so focus is cleared | |
| // too — not merely detached. | |
| CHECK(scope->children().empty()); | |
| CHECK(manager.primaryFocusPtr() == nullptr); | |
| } | |
| TEST_CASE("a dying FocusData detaches its node when no manager is reachable", | |
| "[FocusData]") | |
| { | |
| // Nothing in the chain is registered (a manager torn down around the | |
| // subtree). There is no focus to clear, but the node still must not stay | |
| // parented. | |
| auto scope = make_rcp<FocusNode>(); | |
| { | |
| FocusData data; | |
| scope->addChild(data.focusNode()); | |
| REQUIRE(scope->children().size() == 1); | |
| } | |
| CHECK(scope->children().empty()); | |
| } | |
| 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); | |
| } | |
| // Focusable that can report live world bounds, like FocusData/TextInput do. | |
| class MockBoundedFocusable : public MockFocusable | |
| { | |
| public: | |
| bool hasBounds = true; | |
| AABB liveBounds = AABB(10, 20, 110, 220); | |
| bool worldBounds(AABB& outBounds) override | |
| { | |
| if (!hasBounds) | |
| { | |
| return false; | |
| } | |
| outBounds = liveBounds; | |
| return true; | |
| } | |
| }; | |
| TEST_CASE("primaryFocusBounds prefers live focusable bounds over cached", | |
| "[FocusManager]") | |
| { | |
| FocusManager manager; | |
| MockBoundedFocusable focusable; | |
| auto node = make_rcp<FocusNode>(&focusable); | |
| manager.addChild(nullptr, node); | |
| manager.setFocus(node); | |
| // Stale bounds cached on the node by a previous update pass. | |
| node->worldBounds(AABB(1, 2, 3, 4)); | |
| AABB bounds; | |
| REQUIRE(manager.primaryFocusBounds(bounds) == true); | |
| CHECK(bounds.minX == 10); | |
| CHECK(bounds.minY == 20); | |
| CHECK(bounds.maxX == 110); | |
| CHECK(bounds.maxY == 220); | |
| // When the focusable cannot compute, the cached bounds remain the | |
| // fallback. | |
| focusable.hasBounds = false; | |
| REQUIRE(manager.primaryFocusBounds(bounds) == true); | |
| CHECK(bounds.minX == 1); | |
| CHECK(bounds.maxY == 4); | |
| manager.clearFocus(); | |
| CHECK(manager.primaryFocusBounds(bounds) == false); | |
| } | |
| TEST_CASE("primaryFocusBounds uses cached bounds without a focusable", | |
| "[FocusManager]") | |
| { | |
| // Externally-managed nodes (e.g. created over FFI by a host) have no | |
| // focusable; their host pushes bounds into the node directly. | |
| FocusManager manager; | |
| auto node = make_rcp<FocusNode>(); | |
| manager.addChild(nullptr, node); | |
| manager.setFocus(node); | |
| AABB bounds; | |
| CHECK(manager.primaryFocusBounds(bounds) == false); | |
| node->worldBounds(AABB(5, 6, 7, 8)); | |
| REQUIRE(manager.primaryFocusBounds(bounds) == true); | |
| CHECK(bounds.minX == 5); | |
| CHECK(bounds.maxY == 8); | |
| } | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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("StateMachineInstance::keyInput and textInput route to the focused " | |
| "element", | |
| "[FocusManager]") | |
| { | |
| NoOpFactory factory; | |
| Artboard artboard(&factory); | |
| auto instance = instanceWithFocus(artboard); | |
| StateMachine machine; | |
| StateMachineInstance smi(&machine, instance.get()); | |
| MockFocusable focusable; | |
| focusable.returnValue = true; | |
| auto node = make_rcp<FocusNode>(&focusable); | |
| // Nothing focused yet, so there is nobody to route the events to. | |
| CHECK(smi.keyInput(Key::a, KeyModifiers::none, true, false) == false); | |
| CHECK(smi.textInput("hello") == false); | |
| CHECK(focusable.keyInputCount == 0); | |
| CHECK(focusable.textInputCount == 0); | |
| smi.focusManager()->addChild(nullptr, node); | |
| smi.focusManager()->setFocus(node); | |
| CHECK(smi.keyInput(Key::b, KeyModifiers::shift, true, false) == true); | |
| CHECK(focusable.keyInputCount == 1); | |
| CHECK(focusable.lastKey == Key::b); | |
| CHECK(smi.textInput("world") == true); | |
| CHECK(focusable.textInputCount == 1); | |
| CHECK(focusable.lastText == "world"); | |
| } | |
| TEST_CASE("StateMachineInstance::keyInput and textInput report unhandled " | |
| "events from the focused element", | |
| "[FocusManager]") | |
| { | |
| NoOpFactory factory; | |
| Artboard artboard(&factory); | |
| auto instance = instanceWithFocus(artboard); | |
| StateMachine machine; | |
| StateMachineInstance smi(&machine, instance.get()); | |
| MockFocusable focusable; | |
| focusable.returnValue = false; | |
| auto node = make_rcp<FocusNode>(&focusable); | |
| smi.focusManager()->addChild(nullptr, node); | |
| smi.focusManager()->setFocus(node); | |
| // The focused element saw both events but declined to handle them. | |
| CHECK(smi.keyInput(Key::escape, KeyModifiers::none, true, false) == false); | |
| CHECK(smi.textInput("ignored") == false); | |
| CHECK(focusable.keyInputCount == 1); | |
| CHECK(focusable.textInputCount == 1); | |
| } | |
| TEST_CASE("StateMachineInstance::keyInput and textInput use the external focus " | |
| "manager when set", | |
| "[FocusManager]") | |
| { | |
| NoOpFactory factory; | |
| Artboard artboard(&factory); | |
| auto instance = instanceWithFocus(artboard); | |
| StateMachine machine; | |
| StateMachineInstance smi(&machine, instance.get()); | |
| // The artboard owns the manager the state machine uses by default. | |
| auto* ownManager = instance->ensureFocusManager(); | |
| MockFocusable internalFocusable; | |
| internalFocusable.returnValue = true; | |
| auto internalNode = make_rcp<FocusNode>(&internalFocusable); | |
| ownManager->addChild(nullptr, internalNode); | |
| ownManager->setFocus(internalNode); | |
| FocusManager external; | |
| MockFocusable externalFocusable; | |
| externalFocusable.returnValue = true; | |
| auto externalNode = make_rcp<FocusNode>(&externalFocusable); | |
| external.addChild(nullptr, externalNode); | |
| external.setFocus(externalNode); | |
| smi.setExternalFocusManager(&external); | |
| CHECK(smi.keyInput(Key::c, KeyModifiers::none, true, false) == true); | |
| CHECK(smi.textInput("external") == true); | |
| // Events land on the external tree, not the internal one. | |
| CHECK(externalFocusable.keyInputCount == 1); | |
| CHECK(externalFocusable.textInputCount == 1); | |
| CHECK(externalFocusable.lastText == "external"); | |
| CHECK(internalFocusable.keyInputCount == 0); | |
| CHECK(internalFocusable.textInputCount == 0); | |
| smi.setExternalFocusManager(nullptr); | |
| } | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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 = instanceWithFocus(artboard); | |
| 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); | |
| } | |
| // ============================================================================= | |
| // Re-homing focus when the target disappears | |
| // ============================================================================= | |
| TEST_CASE("FocusManager re-homes focus to a sibling leaf when the target hides", | |
| "[FocusManager]") | |
| { | |
| FocusManager manager; | |
| MockFocusable parentFocusable, leafAFocusable, leafBFocusable; | |
| auto parent = make_rcp<FocusNode>(&parentFocusable); | |
| auto leafA = make_rcp<FocusNode>(&leafAFocusable); | |
| auto leafB = make_rcp<FocusNode>(&leafBFocusable); | |
| manager.addChild(nullptr, parent); | |
| manager.addChild(parent, leafA); | |
| manager.addChild(parent, leafB); | |
| manager.setFocus(parent); // descends to the first leaf | |
| REQUIRE(manager.primaryFocus() == leafA); | |
| // A hides: focus lands on its sibling, not on the parent and not nowhere. | |
| leafAFocusable.eligible = false; | |
| manager.dropFocusIfFocusTargetHidden(); | |
| CHECK(manager.primaryFocus() == leafB); | |
| CHECK(leafAFocusable.blurredCount == 1); | |
| CHECK(leafBFocusable.focusedCount == 1); | |
| // The shared ancestor never lost focus, so it is not re-notified. | |
| CHECK(parentFocusable.blurredCount == 0); | |
| CHECK(parentFocusable.focusedCount == 1); | |
| } | |
| TEST_CASE("FocusManager re-homes focus to the parent when it is the only " | |
| "remaining stop", | |
| "[FocusManager]") | |
| { | |
| FocusManager manager; | |
| MockFocusable parentFocusable, leafFocusable; | |
| auto parent = make_rcp<FocusNode>(&parentFocusable); | |
| auto leaf = make_rcp<FocusNode>(&leafFocusable); | |
| manager.addChild(nullptr, parent); | |
| manager.addChild(parent, leaf); | |
| manager.setFocus(parent); | |
| REQUIRE(manager.primaryFocus() == leaf); | |
| // No sibling to fall back to, so the parent itself takes focus. | |
| leafFocusable.eligible = false; | |
| manager.dropFocusIfFocusTargetHidden(); | |
| CHECK(manager.primaryFocus() == parent); | |
| CHECK(leafFocusable.blurredCount == 1); | |
| } | |
| TEST_CASE("FocusManager walks past a hidden parent to an eligible grandparent", | |
| "[FocusManager]") | |
| { | |
| FocusManager manager; | |
| MockFocusable grandFocusable, parentFocusable, leafFocusable; | |
| auto grand = make_rcp<FocusNode>(&grandFocusable); | |
| auto parent = make_rcp<FocusNode>(&parentFocusable); | |
| auto leaf = make_rcp<FocusNode>(&leafFocusable); | |
| manager.addChild(nullptr, grand); | |
| manager.addChild(grand, parent); | |
| manager.addChild(parent, leaf); | |
| manager.setFocus(grand); | |
| REQUIRE(manager.primaryFocus() == leaf); | |
| // The whole parent branch hides: skip it, land on the grandparent. | |
| leafFocusable.eligible = false; | |
| parentFocusable.eligible = false; | |
| manager.dropFocusIfFocusTargetHidden(); | |
| CHECK(manager.primaryFocus() == grand); | |
| } | |
| TEST_CASE("FocusManager clears focus when no ancestor can hold it", | |
| "[FocusManager]") | |
| { | |
| FocusManager manager; | |
| MockFocusable parentFocusable, leafFocusable; | |
| auto parent = make_rcp<FocusNode>(&parentFocusable); | |
| auto leaf = make_rcp<FocusNode>(&leafFocusable); | |
| manager.addChild(nullptr, parent); | |
| manager.addChild(parent, leaf); | |
| manager.setFocus(parent); | |
| REQUIRE(manager.primaryFocus() == leaf); | |
| // Everything up the chain is gone, so focus really does clear. | |
| leafFocusable.eligible = false; | |
| parentFocusable.eligible = false; | |
| manager.dropFocusIfFocusTargetHidden(); | |
| CHECK(manager.primaryFocus() == nullptr); | |
| } | |
| TEST_CASE("FocusManager re-homing does not land on a non-focusable ancestor", | |
| "[FocusManager]") | |
| { | |
| FocusManager manager; | |
| // canFocus=false ancestor: authored as a pass-through container. | |
| MockFocusable containerFocusable, leafFocusable; | |
| auto container = make_rcp<FocusNode>(&containerFocusable); | |
| container->canFocus(false); | |
| auto leaf = make_rcp<FocusNode>(&leafFocusable); | |
| manager.addChild(nullptr, container); | |
| manager.addChild(container, leaf); | |
| manager.setFocus(leaf); | |
| REQUIRE(manager.primaryFocus() == leaf); | |
| leafFocusable.eligible = false; | |
| manager.dropFocusIfFocusTargetHidden(); | |
| // The container cannot take focus, and there is nothing above it. | |
| CHECK(manager.primaryFocus() == nullptr); | |
| } | |
| TEST_CASE("FocusManager clears rather than crossing into another root branch", | |
| "[FocusManager]") | |
| { | |
| FocusManager manager; | |
| MockFocusable rootAFocusable, leafAFocusable, rootBFocusable, | |
| leafBFocusable; | |
| auto rootA = make_rcp<FocusNode>(&rootAFocusable); | |
| auto leafA = make_rcp<FocusNode>(&leafAFocusable); | |
| auto rootB = make_rcp<FocusNode>(&rootBFocusable); | |
| auto leafB = make_rcp<FocusNode>(&leafBFocusable); | |
| manager.addChild(nullptr, rootA); | |
| manager.addChild(rootA, leafA); | |
| manager.addChild(nullptr, rootB); | |
| manager.addChild(rootB, leafB); | |
| manager.setFocus(rootA); | |
| REQUIRE(manager.primaryFocus() == leafA); | |
| // The whole first root branch goes away. Re-homing stays inside the | |
| // ancestor chain, so it stops at rootA rather than continuing into the | |
| // second branch — an unrelated tree, a separate artboard in practice. | |
| // Focus clears; Tab still reaches rootB, it just isn't jumped to. | |
| leafAFocusable.eligible = false; | |
| rootAFocusable.eligible = false; | |
| manager.dropFocusIfFocusTargetHidden(); | |
| CHECK(manager.primaryFocus() == nullptr); | |
| CHECK(leafBFocusable.focusedCount == 0); | |
| } | |
| TEST_CASE("FocusManager clears when a focused root node hides", | |
| "[FocusManager]") | |
| { | |
| FocusManager manager; | |
| MockFocusable rootAFocusable, rootBFocusable; | |
| auto rootA = make_rcp<FocusNode>(&rootAFocusable); | |
| auto rootB = make_rcp<FocusNode>(&rootBFocusable); | |
| manager.addChild(nullptr, rootA); | |
| manager.addChild(nullptr, rootB); | |
| manager.setFocus(rootA); | |
| REQUIRE(manager.primaryFocus() == rootA); | |
| // Same rule with no ancestors at all to walk: a root node's siblings are | |
| // other root branches, so there is nothing in scope to re-home to. | |
| rootAFocusable.eligible = false; | |
| manager.dropFocusIfFocusTargetHidden(); | |
| CHECK(manager.primaryFocus() == nullptr); | |
| CHECK(rootBFocusable.focusedCount == 0); | |
| } | |
| // ============================================================================= | |
| // Re-descending focus when a hidden child reappears | |
| // ============================================================================= | |
| TEST_CASE("FocusManager descends focus to a child that becomes eligible", | |
| "[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); | |
| // With the child hidden, the parent is a leaf and keeps focus itself. | |
| childFocusable.eligible = false; | |
| manager.setFocus(parent); | |
| REQUIRE(manager.primaryFocus() == parent); | |
| CHECK(parentFocusable.focusedCount == 1); | |
| // Still a leaf, so an update pass changes nothing. | |
| manager.descendFocusToLeaf(nullptr); | |
| CHECK(manager.primaryFocus() == parent); | |
| CHECK(childFocusable.focusedCount == 0); | |
| // The child becomes visible: the parent has silently become a scope, and | |
| // focus has to follow to the leaf Tab would have picked. | |
| childFocusable.eligible = true; | |
| manager.descendFocusToLeaf(nullptr); | |
| CHECK(manager.primaryFocus() == child); | |
| CHECK(childFocusable.focusedCount == 1); | |
| // The parent stays on the focus path, so it is neither blurred nor | |
| // re-focused. | |
| CHECK(parentFocusable.blurredCount == 0); | |
| CHECK(parentFocusable.focusedCount == 1); | |
| // Focus rests on a leaf again; further passes are no-ops. | |
| manager.descendFocusToLeaf(nullptr); | |
| CHECK(manager.primaryFocus() == child); | |
| CHECK(childFocusable.focusedCount == 1); | |
| } | |
| TEST_CASE("FocusManager only descends focus for the root that just updated", | |
| "[FocusManager]") | |
| { | |
| NoOpFactory factory; | |
| Artboard artboardA(&factory); | |
| Artboard artboardB(&factory); | |
| FocusManager manager; | |
| MockFocusable parentFocusable, childFocusable; | |
| parentFocusable.artboard = &artboardB; | |
| childFocusable.artboard = &artboardB; | |
| auto parent = make_rcp<FocusNode>(&parentFocusable); | |
| auto child = make_rcp<FocusNode>(&childFocusable); | |
| manager.addChild(nullptr, parent); | |
| manager.addChild(parent, child); | |
| childFocusable.eligible = false; | |
| manager.setFocus(parent); | |
| REQUIRE(manager.primaryFocus() == parent); | |
| childFocusable.eligible = true; | |
| // Root A advancing must not touch a target living in root B: only B's own | |
| // update pass has refreshed what eligibility reads. | |
| manager.descendFocusToLeaf(&artboardA); | |
| CHECK(manager.primaryFocus() == parent); | |
| // B's pass does the descent. | |
| manager.descendFocusToLeaf(&artboardB); | |
| CHECK(manager.primaryFocus() == child); | |
| } | |
| TEST_CASE("FocusManager scopes descent by where focus would land", | |
| "[FocusManager]") | |
| { | |
| NoOpFactory factory; | |
| Artboard artboardA(&factory); | |
| Artboard artboardB(&factory); | |
| FocusManager manager; | |
| // A host-created parent: no artboard backs it, so no root owns it. Its | |
| // child does live in a real artboard, which is the eligibility the | |
| // descent would be acting on. | |
| MockFocusable parentFocusable, childFocusable; | |
| childFocusable.artboard = &artboardB; | |
| auto parent = make_rcp<FocusNode>(&parentFocusable); | |
| auto child = make_rcp<FocusNode>(&childFocusable); | |
| manager.addChild(nullptr, parent); | |
| manager.addChild(parent, child); | |
| childFocusable.eligible = false; | |
| manager.setFocus(parent); | |
| REQUIRE(manager.primaryFocus() == parent); | |
| childFocusable.eligible = true; | |
| // Scoping on the unattributable parent would let root A descend into | |
| // root B's child on B's stale state; scoping on the destination defers. | |
| manager.descendFocusToLeaf(&artboardA); | |
| CHECK(manager.primaryFocus() == parent); | |
| // And it still descends — B's own pass claims it, rather than the | |
| // unattributable target being deferred forever. | |
| manager.descendFocusToLeaf(&artboardB); | |
| CHECK(manager.primaryFocus() == child); | |
| } | |
| TEST_CASE("FocusManager only drops a hidden target for its own root", | |
| "[FocusManager]") | |
| { | |
| NoOpFactory factory; | |
| Artboard artboardA(&factory); | |
| Artboard artboardB(&factory); | |
| FocusManager manager; | |
| MockFocusable parentFocusable, leafAFocusable, leafBFocusable; | |
| parentFocusable.artboard = &artboardB; | |
| leafAFocusable.artboard = &artboardB; | |
| leafBFocusable.artboard = &artboardB; | |
| auto parent = make_rcp<FocusNode>(&parentFocusable); | |
| auto leafA = make_rcp<FocusNode>(&leafAFocusable); | |
| auto leafB = make_rcp<FocusNode>(&leafBFocusable); | |
| manager.addChild(nullptr, parent); | |
| manager.addChild(parent, leafA); | |
| manager.addChild(parent, leafB); | |
| manager.setFocus(parent); | |
| REQUIRE(manager.primaryFocus() == leafA); | |
| leafAFocusable.eligible = false; | |
| // Root A's update pass hasn't refreshed anything in root B, so it must | |
| // not re-home B's target off what it reads there. | |
| manager.dropFocusIfFocusTargetHidden(&artboardA); | |
| CHECK(manager.primaryFocus() == leafA); | |
| // B's own pass does it. | |
| manager.dropFocusIfFocusTargetHidden(&artboardB); | |
| CHECK(manager.primaryFocus() == leafB); | |
| } | |
| } // 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); | |
| // The advance that zeroes the opacity is the one that drops the focus: | |
| // the check runs after updatePass, so it reads this frame's opacity | |
| // rather than the previous frame's. | |
| stateMachine->advanceAndApply(0.016f); | |
| REQUIRE(focusManager->primaryFocus() == nullptr); | |
| 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->artboardNamed("Main"); | |
| 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->artboardNamed("Main"); | |
| 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); | |
| } | |
| } | |
| // The test asset carries Luau bytecode scripts, which only the Luau | |
| // backend runs. | |
| #ifdef WITH_RIVE_SCRIPTING_LUAU | |
| 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")); | |
| } | |
| #endif | |
| 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("State machines over one artboard instance share the artboard's " | |
| "FocusManager", | |
| "[FocusManager][list]") | |
| { | |
| // The FocusManager belongs to the artboard, not to a state machine. A | |
| // second StateMachineInstance over the same ArtboardInstance must reuse it | |
| // rather than stand up a second manager and migrate every FocusNode onto | |
| // it. That migration is what left persistent nodes -- the component list's | |
| // scope and row nodes, which outlive any state machine -- stamped with a | |
| // manager that could later die while they still pointed at it. | |
| auto file = ReadRiveFile("assets/list_focus_order.riv"); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| // Root instances own a manager from the moment they are instanced, before | |
| // any state machine exists. | |
| auto* fm = artboard->focusManager(); | |
| REQUIRE(fm != nullptr); | |
| auto vmi = file->createDefaultViewModelInstance(artboard.get()); | |
| REQUIRE(vmi != nullptr); | |
| artboard->bindViewModelInstance(vmi); | |
| auto first = artboard->stateMachineAt(0); | |
| REQUIRE(first != nullptr); | |
| CHECK(first->focusManager() == fm); | |
| first->advanceAndApply(0.016f); | |
| REQUIRE(artboard->artboardComponentLists().size() == 1); | |
| auto* list = artboard->artboardComponentLists()[0]; | |
| REQUIRE(list->listScopeFocusNode() != nullptr); | |
| REQUIRE(list->listScopeFocusNode()->manager() == fm); | |
| // This is the shape the Android controller produces: an input queued for a | |
| // state machine that has not been instanced yet builds a second one over | |
| // the live artboard. | |
| auto second = artboard->stateMachineAt(0); | |
| REQUIRE(second != nullptr); | |
| CHECK(second->focusManager() == fm); | |
| CHECK(first->focusManager() == fm); | |
| CHECK(artboard->focusManager() == fm); | |
| // The list's persistent nodes were not migrated onto a different manager. | |
| auto scope = list->listScopeFocusNode(); | |
| REQUIRE(scope != nullptr); | |
| CHECK(scope->manager() == fm); | |
| for (auto& row : scope->children()) | |
| { | |
| CHECK(row->manager() == fm); | |
| } | |
| CHECK(fm->focusNext() == true); | |
| CHECK(fm->primaryFocus() != nullptr); | |
| } | |
| TEST_CASE("Destroying one state machine leaves another's focus tree intact", | |
| "[FocusManager][list]") | |
| { | |
| // ~StateMachineInstance used to call cleanupFocusTree() whenever it owned | |
| // the manager, ripping out a tree a second state machine over the same | |
| // artboard was still using. With the manager owned by the artboard there | |
| // is nothing for a state machine to tear down. | |
| auto file = ReadRiveFile("assets/list_focus_order.riv"); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| auto* fm = artboard->focusManager(); | |
| REQUIRE(fm != nullptr); | |
| auto vmi = file->createDefaultViewModelInstance(artboard.get()); | |
| REQUIRE(vmi != nullptr); | |
| artboard->bindViewModelInstance(vmi); | |
| auto first = artboard->stateMachineAt(0); | |
| REQUIRE(first != nullptr); | |
| first->advanceAndApply(0.016f); | |
| auto second = artboard->stateMachineAt(0); | |
| REQUIRE(second != nullptr); | |
| REQUIRE(artboard->artboardComponentLists().size() == 1); | |
| auto* list = artboard->artboardComponentLists()[0]; | |
| REQUIRE(list->listScopeFocusNode() != nullptr); | |
| first.reset(); | |
| // The manager, the artboard's pointer to it, and the list's scope all | |
| // outlive the first state machine. | |
| CHECK(artboard->focusManager() == fm); | |
| CHECK(second->focusManager() == fm); | |
| auto scope = list->listScopeFocusNode(); | |
| REQUIRE(scope != nullptr); | |
| CHECK(scope->manager() == fm); | |
| // And the surviving state machine can still drive focus through it. | |
| CHECK(fm->focusNext() == true); | |
| CHECK(fm->primaryFocus() != nullptr); | |
| second->advanceAndApply(0.016f); | |
| CHECK(fm->primaryFocus() != nullptr); | |
| } | |
| 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); | |
| } | |
| TEST_CASE("Focus bounds track a nested artboard host that moves", | |
| "[FocusManager]") | |
| { | |
| // The focusable's own layout geometry never changes here -- only the | |
| // NestedArtboard hosting it slides. The bounds cached on the FocusNode | |
| // during the update pass are written from that unchanged geometry, so they | |
| // describe where the element used to be. FocusData::worldBounds recomputes | |
| // through the root transform at call time, which is what keeps a focus | |
| // bracket attached to the element as its host animates. | |
| auto file = ReadRiveFile("assets/focus_bounds_moving_host.riv"); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| // Settle without consuming time so the host is still at its authored | |
| // position when the first bounds are read. The scene focuses itself: the | |
| // hosted artboard's entry state fires an event at start and its own | |
| // listener performs the FocusActionTarget. | |
| stateMachine->advanceAndApply(0.0f); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto* focusManager = stateMachine->focusManager(); | |
| REQUIRE(focusManager != nullptr); | |
| REQUIRE(focusManager->primaryFocus() != nullptr); | |
| rive::AABB atRest; | |
| REQUIRE(focusManager->primaryFocusBounds(atRest) == true); | |
| // Host authored at (100, 100); the target layout sits at its artboard's | |
| // origin, since yoga owns a LayoutComponent's position and ignores the | |
| // node's own x/y. Anchoring the start makes a drift in absolute placement | |
| // visible, not just a wrong delta. | |
| CHECK(atRest.minX == Approx(100.0f).margin(0.5f)); | |
| CHECK(atRest.minY == Approx(100.0f).margin(0.5f)); | |
| CHECK(atRest.width() == Approx(120.0f).margin(0.5f)); | |
| CHECK(atRest.height() == Approx(80.0f).margin(0.5f)); | |
| // Run the slide halfway: the full travel is 200pt right and 60pt down over | |
| // 60 frames, linearly. Stop at 30 rather than 60 -- the animation | |
| // ping-pongs, so the far end is a turning point and float accumulation | |
| // could land either side of it. | |
| for (int i = 0; i < 30; i++) | |
| { | |
| stateMachine->advanceAndApply(1.0f / 60.0f); | |
| } | |
| rive::AABB moved; | |
| REQUIRE(focusManager->primaryFocusBounds(moved) == true); | |
| CHECK(moved.minX == Approx(atRest.minX + 100.0f).margin(0.5f)); | |
| CHECK(moved.minY == Approx(atRest.minY + 30.0f).margin(0.5f)); | |
| // The element itself did not resize; only its host moved. | |
| CHECK(moved.width() == Approx(atRest.width()).margin(0.5f)); | |
| CHECK(moved.height() == Approx(atRest.height()).margin(0.5f)); | |
| } | |
| TEST_CASE("Focus change with gamepad navigation", "[silver]") | |
| { | |
| rive::SerializingFactory silver; | |
| auto file = ReadRiveFile("assets/gamepad_inputs_test.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.0f); | |
| auto renderer = silver.makeRenderer(); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| stateMachine->advanceAndApply(0.016f); | |
| artboard->draw(renderer.get()); | |
| // An update is only accepted for a device the state machine has already | |
| // seen connected, so announce the pad before pressing anything. | |
| constexpr int32_t kDeviceId = 0; | |
| rive::GamepadWire connect; | |
| connect.connected(kDeviceId); | |
| REQUIRE(stateMachine->submitGamepadsFromBuffer(connect.buf.data(), | |
| connect.buf.size())); | |
| silver.addFrame(); | |
| rive::GamepadWire press; | |
| press.button(kDeviceId, rive::StandardGamepadButton::rightShoulder, 1.0f); | |
| REQUIRE(stateMachine->submitGamepadsFromBuffer(press.buf.data(), | |
| press.buf.size())); | |
| stateMachine->advanceAndApply(0.016f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| rive::GamepadWire release; | |
| release.button(kDeviceId, rive::StandardGamepadButton::rightShoulder, 0.0f); | |
| REQUIRE(stateMachine->submitGamepadsFromBuffer(release.buf.data(), | |
| release.buf.size())); | |
| stateMachine->advanceAndApply(0.016f); | |
| artboard->draw(renderer.get()); | |
| CHECK(silver.matches("gamepad_inputs_test")); | |
| } | |
| TEST_CASE("Uncollapse and focus element on the same action", "[silver]") | |
| { | |
| rive::SerializingFactory silver; | |
| auto file = ReadRiveFile("assets/gamepad_inputs_test.riv", &silver); | |
| auto artboard = file->artboardNamed("UncollapsedParent"); | |
| REQUIRE(artboard != nullptr); | |
| silver.frameSize(artboard->width(), artboard->height()); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| auto vmi = file->createDefaultViewModelInstance(artboard.get()); | |
| stateMachine->bindViewModelInstance(vmi); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto renderer = silver.makeRenderer(); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| stateMachine->pointerDown(rive::Vec2D(475.0f, 475.0f)); | |
| stateMachine->pointerUp(rive::Vec2D(475.0f, 475.0f)); | |
| stateMachine->advanceAndApply(0.016f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| stateMachine->pointerDown(rive::Vec2D(475.0f, 475.0f)); | |
| stateMachine->pointerUp(rive::Vec2D(475.0f, 475.0f)); | |
| stateMachine->advanceAndApply(0.016f); | |
| artboard->draw(renderer.get()); | |
| CHECK(silver.matches("gamepad_inputs_test-collapsing")); | |
| } |