blob: 4dcd0eeacfbd8dfbf5ff5cc5eaa9c06ae6530339 [file]
#include "rive/artboard.hpp"
#include "rive/artboard_component_list.hpp"
#include "rive/layout_component.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/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 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());
}
// list_in_group_joins_layout.riv is the same shape but the list carries the
// ParticipatesInLayout drawable flag — the opt-in a newly authored list
// gets. The group is then transparent to it, so the artboard collects it and is
// no longer a leaf.
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 flag lets the layout see through the group to the list.
REQUIRE_FALSE(artboard->isLeaf());
}
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);
}
}