| #include "rive/artboard.hpp" | |
| #include "rive/artboard_component_list.hpp" | |
| #include "rive/layout_component.hpp" | |
| #include "rive/nested_artboard_layout.hpp" | |
| #include "rive/node.hpp" | |
| #include "rive/layout/layout_component_style.hpp" | |
| #include "rive/layout/layout_node_provider.hpp" | |
| #include "rive/math/aabb.hpp" | |
| #include "rive/shapes/points_path.hpp" | |
| #include "rive/shapes/rectangle.hpp" | |
| #include "rive/shapes/shape.hpp" | |
| #include "rive/solo.hpp" | |
| #include "rive_file_reader.hpp" | |
| #include "rive_testing.hpp" | |
| #include <catch.hpp> | |
| #include <algorithm> | |
| #include <cmath> | |
| #include <vector> | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 200x200 stack containing a single fill/fill parametric-shape participant | |
| // (a LayoutParticipant child). The participant should fill the whole stacked | |
| // cell. | |
| TEST_CASE("a fill participant fills a stack cell from a .riv file", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/stack_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| // Per-axis fill stretches the participant to the full 200x200 cell. | |
| auto bounds = provider->layoutBounds(); | |
| REQUIRE(bounds.width() == 200.0f); | |
| REQUIRE(bounds.height() == 200.0f); | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 200x200 flex container holding a Solo whose active child is a fill/fill | |
| // parametric-shape participant. A Solo is transparent to layout — it provides | |
| // no node of its own, and the layout descends into the active child, so that | |
| // child is sized to the full cell while the inactive one is left out. | |
| TEST_CASE("a Solo's active child is laid out through it from a .riv file", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/solo_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto solos = artboard->find<rive::Solo>(); | |
| REQUIRE(solos.size() == 1); | |
| auto* solo = solos[0]; | |
| // The Solo itself is not a provider — it is transparent. | |
| REQUIRE(rive::LayoutNodeProvider::from(solo) == nullptr); | |
| auto* active = solo->activeComponent(); | |
| REQUIRE(active != nullptr); | |
| auto* provider = rive::LayoutNodeProvider::from(active); | |
| REQUIRE(provider != nullptr); | |
| auto bounds = provider->layoutBounds(); | |
| REQUIRE(bounds.width() == 200.0f); | |
| REQUIRE(bounds.height() == 200.0f); | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 200x200 flex container holding a Solo whose active child is a plain | |
| // 500x500 shape with no LayoutParticipant. Regression (#13033): content-sizing | |
| // treated a Solo as a barrier, so the child was never controlSize'd and kept | |
| // its authored 500x500. A Solo is transparent to that walk too — only groups | |
| // stop it — so the child is sized to the container. | |
| TEST_CASE("a non-participating child in a Solo is content-sized from a .riv " | |
| "file", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/solo_legacy_child.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto solos = artboard->find<rive::Solo>(); | |
| REQUIRE(solos.size() == 1); | |
| auto* active = solos[0]->activeComponent(); | |
| REQUIRE(active != nullptr); | |
| // No participant: it provides no layout node, so only content-sizing can | |
| // bring it to the container's box. | |
| REQUIRE(rive::LayoutNodeProvider::from(active) == nullptr); | |
| // A non-participating shape's size lives on its ParametricPath child, which | |
| // is what Shape::controlSize forwards to. | |
| auto rects = artboard->find<rive::Rectangle>(); | |
| REQUIRE(rects.size() == 1); | |
| REQUIRE(rects[0]->width() == 200.0f); | |
| REQUIRE(rects[0]->height() == 200.0f); | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 100x100 single-cell grid with a hug/hug parametric-shape participant (a | |
| // 10x10 rectangle). The participant hugs its intrinsic 10x10 instead of | |
| // stretching to the cell. | |
| TEST_CASE("a hug participant hugs its content from a .riv file", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/hug_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| // A single pass is enough: layout solves before geometry builds, but a | |
| // parametric path reports its declared box up front (tryPropertyBounds), so | |
| // it measures correctly without waiting for a second advance — matching how | |
| // it behaves as plain layout content. | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| // Sized to the rectangle's intrinsic bounds, not the 100px cell. | |
| auto bounds = provider->layoutBounds(); | |
| REQUIRE(bounds.width() == Approx(10.0f)); | |
| REQUIRE(bounds.height() == Approx(10.0f)); | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 200x200 flex container with a fixed 60x40 participant. | |
| TEST_CASE("a fixed-size participant keeps its size from a .riv file", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/fixed_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| auto bounds = provider->layoutBounds(); | |
| REQUIRE(bounds.width() == Approx(60.0f)); | |
| REQUIRE(bounds.height() == Approx(40.0f)); | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 200x200 flex container with two fill participants, one display:none. The | |
| // hidden one collapses and leaves the flow, so the other is the sole in-flow | |
| // child and fills the container. | |
| TEST_CASE("a display:none participant collapses and leaves the flow " | |
| "from a .riv file", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/display_none_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 2); | |
| int collapsed = 0; | |
| rive::Shape* shown = nullptr; | |
| for (auto* shape : shapes) | |
| { | |
| if (shape->isCollapsed()) | |
| { | |
| collapsed++; | |
| } | |
| else | |
| { | |
| shown = shape; | |
| } | |
| } | |
| REQUIRE(collapsed == 1); | |
| REQUIRE(shown != nullptr); | |
| // The sole in-flow child fills the whole container. | |
| auto* provider = rive::LayoutNodeProvider::from(shown); | |
| REQUIRE(provider != nullptr); | |
| auto bounds = provider->layoutBounds(); | |
| REQUIRE(bounds.width() == Approx(200.0f)); | |
| REQUIRE(bounds.height() == Approx(200.0f)); | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 200x200 flex container with a participant whose fixed width (10) is clamped | |
| // up by minWidth (50) and whose fill height is clamped down by maxHeight (30). | |
| TEST_CASE("min/max constraints clamp a participant slot from a .riv file", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/constrained_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| auto bounds = provider->layoutBounds(); | |
| REQUIRE(bounds.width() == Approx(50.0f)); | |
| REQUIRE(bounds.height() == Approx(30.0f)); | |
| } | |
| // The Solo's active-child index helpers (used by data binding) resolve and | |
| // switch the active soloable option. Reuses the solo forwarding fixture, whose | |
| // Solo has two shape options with the first active. | |
| TEST_CASE("a Solo's active-child index helpers work from a .riv file", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/solo_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto solos = artboard->find<rive::Solo>(); | |
| REQUIRE(solos.size() == 1); | |
| auto* solo = solos[0]; | |
| // The first soloable option is active. | |
| REQUIRE(solo->getActiveChildIndex() == 0); | |
| // Switch to the second option by index; the active index follows. | |
| solo->updateByIndex(1); | |
| REQUIRE(solo->getActiveChildIndex() == 1); | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 200x200 flex container with 1s linear layout interpolation and a fill/fill | |
| // participant. Shrinking the container makes the participant animate toward the | |
| // new slot rather than snapping. | |
| TEST_CASE("a participant animates its slot under an animated layout", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/animated_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| // First solve snaps to the full 200-wide container (no animate-in). | |
| REQUIRE(provider->layoutBounds().width() == Approx(200.0f)); | |
| // Find the (non-artboard) container and shrink it to 100 wide. | |
| rive::LayoutComponent* container = nullptr; | |
| for (auto* lc : artboard->find<rive::LayoutComponent>()) | |
| { | |
| if (!lc->is<rive::Artboard>() && lc->style() != nullptr) | |
| { | |
| container = lc; | |
| break; | |
| } | |
| } | |
| REQUIRE(container != nullptr); | |
| container->width(100.0f); | |
| // Drive the 1s linear interpolation in steps; the participant should pass | |
| // through intermediate widths rather than jumping straight to 100. | |
| float mid = 200.0f; | |
| for (int i = 0; i < 5; i++) | |
| { | |
| artboard->advance(0.2f); | |
| float w = provider->layoutBounds().width(); | |
| if (w > 100.0f && w < 200.0f) | |
| { | |
| mid = w; | |
| } | |
| } | |
| REQUIRE(mid < 200.0f); // animated (didn't stay at 200) | |
| REQUIRE(mid > 100.0f); // caught mid-interpolation (didn't snap to 100) | |
| // Settle: the animation completes at the new slot. | |
| for (int i = 0; i < 3; i++) | |
| { | |
| artboard->advance(1.0f); | |
| } | |
| REQUIRE(provider->layoutBounds().width() == Approx(100.0f)); | |
| } | |
| // Re-targeting an in-flight layout animation exercises the smoothing / | |
| // double-buffer path (LayoutParticipant::applyInterpolation isSmoothing block). | |
| TEST_CASE("a participant re-targets an in-flight layout animation", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/animated_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| rive::LayoutComponent* container = nullptr; | |
| for (auto* lc : artboard->find<rive::LayoutComponent>()) | |
| { | |
| if (!lc->is<rive::Artboard>() && lc->style() != nullptr) | |
| { | |
| container = lc; | |
| break; | |
| } | |
| } | |
| REQUIRE(container != nullptr); | |
| // Start animating toward 100 and advance until it's genuinely in flight. | |
| // (The first tick only accrues elapsed before it visibly moves, and how | |
| // many frames that takes varies, so loop until it drops below 200 rather | |
| // than assuming a fixed frame count.) | |
| container->width(100.0f); | |
| float midWidth = 200.0f; | |
| for (int i = 0; i < 8 && midWidth >= 200.0f; i++) | |
| { | |
| artboard->advance(0.1f); | |
| midWidth = provider->layoutBounds().width(); | |
| } | |
| REQUIRE(midWidth < 200.0f); // in flight, not snapped | |
| REQUIRE(midWidth > 100.0f); // not yet settled at the target | |
| // Re-target toward 50 while in flight (elapsed != 0), so the participant | |
| // smooths from its current position rather than restarting. | |
| container->width(50.0f); | |
| for (int i = 0; i < 8; i++) | |
| { | |
| artboard->advance(1.0f); | |
| } | |
| REQUIRE(provider->layoutBounds().width() == Approx(50.0f)); | |
| } | |
| // Turning a parent layout's interpolation off frees the participant's lazy | |
| // animation state (LayoutParticipant::cascadeLayoutStyle free path) and it | |
| // snaps thereafter. | |
| TEST_CASE("disabling a layout's interpolation frees participant animation", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/animated_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); // allocates the participant's animation state | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| rive::LayoutComponent* container = nullptr; | |
| for (auto* lc : artboard->find<rive::LayoutComponent>()) | |
| { | |
| if (!lc->is<rive::Artboard>() && lc->style() != nullptr) | |
| { | |
| container = lc; | |
| break; | |
| } | |
| } | |
| REQUIRE(container != nullptr); | |
| // Disable animation (interpolationTime -> 0) and let it re-cascade. | |
| container->style()->interpolationTime(0.0f); | |
| artboard->advance(0.0f); | |
| // Now a slot change snaps immediately instead of animating. | |
| container->width(100.0f); | |
| artboard->advance(0.016f); | |
| REQUIRE(provider->layoutBounds().width() == Approx(100.0f)); | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 200x200 two-column grid (100px columns, one 200px row) with two | |
| // participating shapes — one fill/fill, one fill-width/fixed-50-height. Covers | |
| // the grid branch of syncStyleChanges for participants (justify-self stretch | |
| // and both align-self states), which the LayoutComponent-cell grid tests don't. | |
| TEST_CASE("participants size to grid cells from a .riv file", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/grid_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 2); | |
| std::vector<rive::LayoutNodeProvider*> providers; | |
| for (auto* shape : shapes) | |
| { | |
| auto* p = rive::LayoutNodeProvider::from(shape); | |
| REQUIRE(p != nullptr); | |
| providers.push_back(p); | |
| } | |
| // Order by column x: [0] is column 1, [1] is column 2. | |
| std::sort(providers.begin(), providers.end(), [](auto* a, auto* b) { | |
| return a->layoutBounds().left() < b->layoutBounds().left(); | |
| }); | |
| // Column 1: fill/fill fills the whole 100x200 cell. | |
| REQUIRE(providers[0]->layoutBounds().width() == Approx(100.0f)); | |
| REQUIRE(providers[0]->layoutBounds().height() == Approx(200.0f)); | |
| // Column 2: fill width, fixed 50 height. | |
| REQUIRE(providers[1]->layoutBounds().width() == Approx(100.0f)); | |
| REQUIRE(providers[1]->layoutBounds().height() == Approx(50.0f)); | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // like animated_participant but the layout uses a custom cubic (eased) | |
| // interpolator, so the participant's interpolation runs through the | |
| // KeyFrameInterpolator::transform path (uncovered by the linear fixture). | |
| TEST_CASE("a participant animates its slot with a cubic interpolator", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/animated_cubic_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| REQUIRE(provider->layoutBounds().width() == Approx(200.0f)); | |
| rive::LayoutComponent* container = nullptr; | |
| for (auto* lc : artboard->find<rive::LayoutComponent>()) | |
| { | |
| if (!lc->is<rive::Artboard>() && lc->style() != nullptr) | |
| { | |
| container = lc; | |
| break; | |
| } | |
| } | |
| REQUIRE(container != nullptr); | |
| container->width(100.0f); | |
| // The eased curve still moves 200 -> 100 over 1s, just non-linearly. | |
| float mid = 200.0f; | |
| for (int i = 0; i < 8; i++) | |
| { | |
| artboard->advance(0.15f); | |
| float w = provider->layoutBounds().width(); | |
| if (w > 100.0f && w < 200.0f) | |
| { | |
| mid = w; | |
| } | |
| } | |
| REQUIRE(mid < 200.0f); // eased in-flight (didn't stay at 200) | |
| REQUIRE(mid > 100.0f); // caught mid-interpolation (didn't snap) | |
| for (int i = 0; i < 3; i++) | |
| { | |
| artboard->advance(1.0f); | |
| } | |
| REQUIRE(provider->layoutBounds().width() == Approx(100.0f)); | |
| } | |
| // Re-targeting an in-flight cubic animation a second time while it's already | |
| // smoothing exercises the double-buffer copy in updateLayoutBounds and the | |
| // eased smoothing branch of applyInterpolation. | |
| TEST_CASE("a participant re-targets a cubic animation while smoothing", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/animated_cubic_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| rive::LayoutComponent* container = nullptr; | |
| for (auto* lc : artboard->find<rive::LayoutComponent>()) | |
| { | |
| if (!lc->is<rive::Artboard>() && lc->style() != nullptr) | |
| { | |
| container = lc; | |
| break; | |
| } | |
| } | |
| REQUIRE(container != nullptr); | |
| // Get into flight toward 100. | |
| container->width(100.0f); | |
| float w = 200.0f; | |
| for (int i = 0; i < 8 && w >= 200.0f; i++) | |
| { | |
| artboard->advance(0.1f); | |
| w = provider->layoutBounds().width(); | |
| } | |
| REQUIRE(w < 200.0f); | |
| // Retarget toward 80 (begins smoothing), advance one small step (< 1s so it | |
| // stays smoothing), then retarget again toward 50 while still smoothing — | |
| // the second retarget copies the in-flight buffer. | |
| container->width(80.0f); | |
| artboard->advance(0.1f); | |
| container->width(50.0f); | |
| for (int i = 0; i < 20; i++) | |
| { | |
| artboard->advance(1.0f); | |
| } | |
| REQUIRE(provider->layoutBounds().width() == Approx(50.0f)); | |
| } | |
| // Asset generated by gen_layout_fixtures.py: a 200x200 stack whose only child | |
| // is a group holding a fill/fill participant. Groups are transparent to layout, | |
| // so the stack descends through and sizes the participant to the full cell — | |
| // the same result as stack_participant.riv, where it is a direct child. | |
| TEST_CASE("a participant inside a group is laid out through it", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/group_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 1); | |
| // The group itself provides no layout node — it is transparent. | |
| REQUIRE(rive::LayoutNodeProvider::from(shapes[0]->parent()) == nullptr); | |
| auto* provider = rive::LayoutNodeProvider::from(shapes[0]); | |
| REQUIRE(provider != nullptr); | |
| auto bounds = provider->layoutBounds(); | |
| REQUIRE(bounds.width() == 200.0f); | |
| REQUIRE(bounds.height() == 200.0f); | |
| } | |
| // Asset generated by gen_layout_fixtures.py: a 200x200 stack containing a group | |
| // that holds both a nested group wrapping a participant and a Solo whose active | |
| // child is one. Covers multi-level descent and a Solo inside a group, which was | |
| // impossible while a Solo forwarded a single provider. | |
| TEST_CASE("participants nested in groups and in a grouped Solo are laid out", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/nested_group_participant.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto solos = artboard->find<rive::Solo>(); | |
| REQUIRE(solos.size() == 1); | |
| auto* solo = solos[0]; | |
| auto* active = solo->activeComponent(); | |
| REQUIRE(active != nullptr); | |
| // The Solo sits inside a group, and its active child is still collected. | |
| auto* activeProvider = rive::LayoutNodeProvider::from(active); | |
| REQUIRE(activeProvider != nullptr); | |
| REQUIRE(activeProvider->layoutBounds().width() == 200.0f); | |
| REQUIRE(activeProvider->layoutBounds().height() == 200.0f); | |
| // The inactive sibling is never exposed, so the stack never sizes it. | |
| rive::Component* inactive = nullptr; | |
| for (auto* child : solo->children()) | |
| { | |
| if (child != active && child->is<rive::Shape>()) | |
| { | |
| inactive = child; | |
| } | |
| } | |
| REQUIRE(inactive != nullptr); | |
| auto* inactiveProvider = rive::LayoutNodeProvider::from(inactive); | |
| REQUIRE(inactiveProvider != nullptr); | |
| REQUIRE(inactiveProvider->layoutBounds().width() != 200.0f); | |
| // The participant two groups deep is collected like a direct child. | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(shapes.size() == 3); | |
| rive::Shape* deep = nullptr; | |
| for (auto* shape : shapes) | |
| { | |
| if (shape->parent() != solo) | |
| { | |
| deep = shape; | |
| } | |
| } | |
| REQUIRE(deep != nullptr); | |
| auto* deepProvider = rive::LayoutNodeProvider::from(deep); | |
| REQUIRE(deepProvider != nullptr); | |
| REQUIRE(deepProvider->layoutBounds().width() == 200.0f); | |
| REQUIRE(deepProvider->layoutBounds().height() == 200.0f); | |
| } | |
| // clipping_and_draw_order.riv has an ArtboardComponentList inside a plain | |
| // group. A list provides a layout node unconditionally — it never opted in the | |
| // way a participant does — so a group between it and its layout is how a file | |
| // asks for free-form items placed by x/y or a follow-path constraint. It has to | |
| // stay opaque to it, or the list is pulled into the yoga tree and re-solved | |
| // every frame. | |
| TEST_CASE("an artboard component list inside a group stays out of the layout", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/clipping_and_draw_order.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto lists = artboard->find<rive::ArtboardComponentList>(); | |
| REQUIRE(lists.size() == 1); | |
| // It hangs off a group rather than the artboard... | |
| // (copied to a local: REQUIRE binds by reference, and an in-class static | |
| // const has no out-of-line definition to bind to.) | |
| const uint16_t nodeTypeKey = rive::NodeBase::typeKey; | |
| REQUIRE(lists[0]->parent()->coreType() == nodeTypeKey); | |
| // ...so the artboard collects no layout children at all. | |
| REQUIRE(artboard->isLeaf()); | |
| // And the list agrees: no layout parent, so items keep being positioned | |
| // from their own x/y. Artboard is itself a LayoutComponent, so an | |
| // unguarded ancestor walk would find one here and flip the list onto | |
| // layout slots it was never given. | |
| REQUIRE(lists[0]->layoutParent() == nullptr); | |
| } | |
| // list_in_group_joins_layout.riv is artboard > 200x200 stack > group > list, | |
| // with the list carrying the ParticipatesInLayout drawable flag — the opt-in a | |
| // newly authored list gets. The group is then transparent to it, so the stack | |
| // collects it through the group and the list agrees that the stack owns it. | |
| TEST_CASE("a flagged artboard component list joins the layout through a group", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/list_in_group_joins_layout.riv"); | |
| auto artboard = file->artboard(); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto lists = artboard->find<rive::ArtboardComponentList>(); | |
| REQUIRE(lists.size() == 1); | |
| const uint16_t nodeTypeKey = rive::NodeBase::typeKey; | |
| REQUIRE(lists[0]->parent()->coreType() == nodeTypeKey); | |
| // The stack is what collects through the group, so it is what has to stop | |
| // being a leaf. Asserting that on the artboard proves nothing here: its | |
| // direct child is the stack, itself a provider, so the artboard is | |
| // non-leaf whatever the flag says. | |
| rive::ContainerComponent* group = lists[0]->parent(); | |
| REQUIRE(group->parent()->is<rive::LayoutComponent>()); | |
| auto* stack = group->parent()->as<rive::LayoutComponent>(); | |
| REQUIRE_FALSE(stack->isLeaf()); | |
| // And the read-back side has to agree with that collection, or the list | |
| // positions items from worldBounds while the stack solves slots for them — | |
| // the two disagreeing is what made this diverge from Dart. It resolves the | |
| // stack and stops there: the artboard is a LayoutComponent too, so an | |
| // overshooting walk would land on it and silently use the wrong frame. | |
| REQUIRE(lists[0]->layoutParent() == stack); | |
| REQUIRE(lists[0]->layoutParent() != artboard); | |
| } | |
| TEST_CASE("a custom-path participant measures before its paths are built", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout_grid_stack.riv"); | |
| auto artboard = file->artboardNamed("GridWithLayoutParticipants"); | |
| REQUIRE(artboard != nullptr); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(!shapes.empty()); | |
| // Deliberately before any advance(): no rawPath has been built yet, which | |
| // is exactly the state the first layout pass observes. | |
| size_t customPathShapes = 0; | |
| for (auto* shape : shapes) | |
| { | |
| INFO("shape: " << shape->name()); | |
| rive::AABB bounds = shape->computeIntrinsicBounds(); | |
| // Never inverted, whatever the path type. | |
| CHECK(bounds.width() >= 0.0f); | |
| CHECK(bounds.height() >= 0.0f); | |
| bool hasCustomPath = false; | |
| for (auto* path : shape->paths()) | |
| { | |
| if (path->is<rive::PointsPath>() && !path->isCollapsed()) | |
| { | |
| hasCustomPath = true; | |
| break; | |
| } | |
| } | |
| if (!hasCustomPath) | |
| { | |
| continue; | |
| } | |
| customPathShapes++; | |
| // Without the on-demand build this measures empty. | |
| CHECK(bounds.width() > 0.0f); | |
| CHECK(bounds.height() > 0.0f); | |
| } | |
| REQUIRE(customPathShapes > 0); | |
| } | |
| TEST_CASE("a participant with an empty path keeps a sane world transform", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout_grid_stack.riv"); | |
| auto artboard = file->artboardNamed("GridWithLayoutParticipants"); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto shapes = artboard->find<rive::Shape>(); | |
| REQUIRE(!shapes.empty()); | |
| // Slots live inside the artboard; the sentinel leak lands at -3.4e38, so | |
| // any generous bound separates the two. | |
| const float limit = 1.0e6f; | |
| for (auto* shape : shapes) | |
| { | |
| INFO("shape: " << shape->name()); | |
| // The cause: bounds must never come back inverted. | |
| rive::AABB intrinsic = shape->computeIntrinsicBounds(); | |
| CHECK(intrinsic.width() >= 0.0f); | |
| CHECK(intrinsic.height() >= 0.0f); | |
| // The symptom: the anchor derived from them stays near the slot. | |
| const rive::Mat2D& world = shape->worldTransform(); | |
| CHECK(std::fabs(world[4]) < limit); | |
| CHECK(std::fabs(world[5]) < limit); | |
| } | |
| } | |
| // Asset generated by rive_core/test/layout_participant_export_test.dart: | |
| // a 200x200 COLUMN flex container holding a Solo whose active child is a | |
| // layout-mode nested artboard. The column collects it through the Solo, so the | |
| // axis it sizes against is the column's. Asking parent() found the Solo and | |
| // fell back to "row", sizing the wrong axis — the editor showed the column's. | |
| TEST_CASE("a nested artboard layout resolves its owning layout through a Solo", | |
| "[layoutparticipant]") | |
| { | |
| auto file = ReadRiveFile("assets/layout/solo_nested_artboard_layout.riv"); | |
| auto artboard = file->artboard("Column Artboard"); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto nested = artboard->find<rive::NestedArtboardLayout>(); | |
| REQUIRE(nested.size() == 1); | |
| // The immediate parent is the Solo, which owns no layout node... | |
| REQUIRE(nested[0]->parent()->is<rive::Solo>()); | |
| REQUIRE(rive::LayoutNodeProvider::from(nested[0]->parent()) == nullptr); | |
| // ...so the axis has to come from the column above it. | |
| REQUIRE(nested[0]->isRow() == false); | |
| REQUIRE(nested[0]->isStack() == false); | |
| } |