| #include "rive/text/text.hpp" | |
| #include "rive/generated/core_registry.hpp" | |
| #include "rive/text/text_style.hpp" | |
| #include "rive/text/text_value_run.hpp" | |
| #include "rive_file_reader.hpp" | |
| #include "rive_testing.hpp" | |
| #include "rive/text/glyph_lookup.hpp" | |
| #include "rive/text/text_modifier_range.hpp" | |
| #include "utils/no_op_renderer.hpp" | |
| #include "rive/text/utf.hpp" | |
| #include "rive/text/text_modifier_group.hpp" | |
| #include "utils/serializing_factory.hpp" | |
| #include "rive/viewmodel/viewmodel_instance_string.hpp" | |
| #include "rive/viewmodel/viewmodel_instance_trigger.hpp" | |
| #include "rive/animation/state_machine_instance.hpp" | |
| #include <iostream> | |
| #include <string> | |
| TEST_CASE("file with text loads correctly", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/new_text.riv"); | |
| auto artboard = file->artboard(); | |
| auto textObjects = artboard->find<rive::Text>(); | |
| REQUIRE(textObjects.size() == 5); | |
| auto styleObjects = artboard->find<rive::TextStyle>(); | |
| REQUIRE(styleObjects.size() == 13); | |
| auto runObjects = artboard->find<rive::TextValueRun>(); | |
| REQUIRE(runObjects.size() == 22); | |
| artboard->advance(0.0f); | |
| rive::NoOpRenderer renderer; | |
| artboard->draw(&renderer); | |
| } | |
| TEST_CASE("can query for all text runs", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/new_text.riv"); | |
| auto artboard = file->artboard(); | |
| auto textRunCount = artboard->count<rive::TextValueRun>(); | |
| REQUIRE(textRunCount == 22); | |
| } | |
| TEST_CASE("can query for a text run at a given index", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/hello_world.riv"); | |
| auto artboard = file->artboard(); | |
| auto textRun = artboard->objectAt<rive::TextValueRun>(0); | |
| REQUIRE(textRun->text() == "Hello World!"); | |
| } | |
| TEST_CASE("simple text loads", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/hello_world.riv"); | |
| auto artboard = file->artboard(); | |
| auto textObjects = artboard->find<rive::Text>(); | |
| REQUIRE(textObjects.size() == 1); | |
| auto styleObjects = artboard->find<rive::TextStyle>(); | |
| REQUIRE(styleObjects.size() == 1); | |
| auto runObjects = artboard->find<rive::TextValueRun>(); | |
| REQUIRE(runObjects.size() == 1); | |
| REQUIRE(runObjects[0]->text() == "Hello World!"); | |
| rive::NoOpRenderer renderer; | |
| artboard->advance(0.0f); | |
| artboard->draw(&renderer); | |
| { | |
| REQUIRE(textObjects[0]->shape().size() == 1); | |
| const rive::Paragraph& paragraph = textObjects[0]->shape()[0]; | |
| REQUIRE(paragraph.runs.size() == 1); | |
| REQUIRE(paragraph.runs[0].glyphs.size() == 12); | |
| } | |
| // Changing to "Just Hello" works. | |
| runObjects[0]->text("Just Hello"); | |
| artboard->advance(0.0f); | |
| artboard->draw(&renderer); | |
| { | |
| REQUIRE(textObjects[0]->shape().size() == 1); | |
| const rive::Paragraph& paragraph = textObjects[0]->shape()[0]; | |
| REQUIRE(paragraph.runs.size() == 1); | |
| REQUIRE(paragraph.runs[0].glyphs.size() == 10); | |
| } | |
| // Changing to an empty space " " works. | |
| runObjects[0]->text(" "); | |
| artboard->advance(0.0f); | |
| artboard->draw(&renderer); | |
| { | |
| REQUIRE(textObjects[0]->shape().size() == 1); | |
| const rive::Paragraph& paragraph = textObjects[0]->shape()[0]; | |
| REQUIRE(paragraph.runs.size() == 1); | |
| REQUIRE(paragraph.runs[0].glyphs.size() == 1); | |
| } | |
| // Changing to completely empty works. | |
| runObjects[0]->text(""); | |
| artboard->advance(0.0f); | |
| artboard->draw(&renderer); | |
| { | |
| REQUIRE(textObjects[0]->shape().size() == 0); | |
| REQUIRE(textObjects[0]->localBounds().width() == 0.0f); | |
| REQUIRE(textObjects[0]->localBounds().height() == 0.0f); | |
| } | |
| } | |
| TEST_CASE("vertical trim shrinks the box to the chosen band", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/hello_world.riv"); | |
| auto artboard = file->artboard(); | |
| auto text = artboard->find<rive::Text>()[0]; | |
| rive::NoOpRenderer renderer; | |
| auto measureHeight = [&](rive::TextTrimTop top, | |
| rive::TextTrimBottom bottom) { | |
| text->verticalTrimValue(rive::packTextVerticalTrim(top, bottom)); | |
| artboard->advance(0.0f); | |
| artboard->draw(&renderer); | |
| return text->localBounds().height(); | |
| }; | |
| // Auto-width so the height is driven entirely by the line metrics. | |
| text->sizingValue((uint32_t)rive::TextSizing::autoWidth); | |
| float normalHeight = | |
| measureHeight(rive::TextTrimTop::none, rive::TextTrimBottom::none); | |
| float normalWidth = text->localBounds().width(); | |
| REQUIRE(normalHeight > 0.0f); | |
| // Cap + baseline removes the ascent above the caps and the whole descent. | |
| float capHeight = | |
| measureHeight(rive::TextTrimTop::cap, rive::TextTrimBottom::alphabetic); | |
| REQUIRE(capHeight > 0.0f); | |
| REQUIRE(capHeight < normalHeight); | |
| REQUIRE(text->localBounds().width() == Approx(normalWidth)); | |
| // x-height sits below the cap line, so trimming to it removes even more. | |
| float exHeight = | |
| measureHeight(rive::TextTrimTop::ex, rive::TextTrimBottom::alphabetic); | |
| REQUIRE(exHeight > 0.0f); | |
| REQUIRE(exHeight < capHeight); | |
| // Trimming only the top leaves a taller box than also trimming the bottom. | |
| float topOnlyHeight = | |
| measureHeight(rive::TextTrimTop::cap, rive::TextTrimBottom::none); | |
| REQUIRE(topOnlyHeight > capHeight); | |
| REQUIRE(topOnlyHeight < normalHeight); | |
| } | |
| TEST_CASE("vertical trim top/bottom uint passthroughs pack into the mask", | |
| "[text]") | |
| { | |
| rive::Text text; | |
| // The two uint bitmask passthroughs write disjoint byte-wide fields of the | |
| // single packed verticalTrimValue, matching packTextVerticalTrim(). | |
| rive::CoreRegistry::setUint(&text, | |
| rive::TextBase::verticalTrimTopValuePropertyKey, | |
| (uint32_t)rive::TextTrimTop::ex); | |
| rive::CoreRegistry::setUint( | |
| &text, | |
| rive::TextBase::verticalTrimBottomValuePropertyKey, | |
| (uint32_t)rive::TextTrimBottom::text); | |
| REQUIRE(text.verticalTrimValue() == | |
| rive::packTextVerticalTrim(rive::TextTrimTop::ex, | |
| rive::TextTrimBottom::text)); | |
| REQUIRE(text.verticalTrimTop() == rive::TextTrimTop::ex); | |
| REQUIRE(text.verticalTrimBottom() == rive::TextTrimBottom::text); | |
| // Read-back through the registry returns the per-field values. | |
| REQUIRE(rive::CoreRegistry::getUint( | |
| &text, | |
| rive::TextBase::verticalTrimTopValuePropertyKey) == | |
| (uint32_t)rive::TextTrimTop::ex); | |
| REQUIRE(rive::CoreRegistry::getUint( | |
| &text, | |
| rive::TextBase::verticalTrimBottomValuePropertyKey) == | |
| (uint32_t)rive::TextTrimBottom::text); | |
| // Rewriting one field leaves the other untouched. | |
| rive::CoreRegistry::setUint(&text, | |
| rive::TextBase::verticalTrimTopValuePropertyKey, | |
| (uint32_t)rive::TextTrimTop::cap); | |
| REQUIRE(text.verticalTrimTop() == rive::TextTrimTop::cap); | |
| REQUIRE(text.verticalTrimBottom() == rive::TextTrimBottom::text); | |
| // Overflow beyond the 8-bit field is masked off, never bleeding into the | |
| // neighbouring field. | |
| rive::CoreRegistry::setUint(&text, | |
| rive::TextBase::verticalTrimTopValuePropertyKey, | |
| 0x1FF); | |
| REQUIRE(rive::CoreRegistry::getUint( | |
| &text, | |
| rive::TextBase::verticalTrimTopValuePropertyKey) == 0xFF); | |
| REQUIRE(text.verticalTrimBottom() == rive::TextTrimBottom::text); | |
| } | |
| TEST_CASE("ellipsis is shown", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/ellipsis.riv"); | |
| auto artboard = file->artboard(); | |
| auto textObjects = artboard->find<rive::Text>(); | |
| REQUIRE(textObjects.size() == 1); | |
| auto styleObjects = artboard->find<rive::TextStyle>(); | |
| REQUIRE(styleObjects.size() == 1); | |
| auto runObjects = artboard->find<rive::TextValueRun>(); | |
| REQUIRE(runObjects.size() == 1); | |
| artboard->advance(0.0f); | |
| auto text = textObjects[0]; | |
| auto lines = text->orderedLines(); | |
| REQUIRE(lines.size() == 1); | |
| auto orderedLine = lines[0]; | |
| int glyphCount = 0; | |
| std::vector<rive::GlyphID> glyphIds; | |
| // for (auto itr = orderedLine.begin(); itr != orderedLine.end(); ++itr) | |
| for (auto glyphItr : orderedLine) | |
| { | |
| auto run = std::get<0>(glyphItr); | |
| size_t glyphIndex = std::get<1>(glyphItr); | |
| glyphIds.push_back(run->glyphs[glyphIndex]); | |
| glyphCount++; | |
| } | |
| // Expect 10 glyphs 'one two...' | |
| REQUIRE(glyphCount == 10); | |
| // Third to last glyph should be the "." for Inter. | |
| REQUIRE(glyphIds[7] == 1405); | |
| // Last three glyphs should be the same '.' | |
| REQUIRE(glyphIds[7] == glyphIds[8]); | |
| REQUIRE(glyphIds[8] == glyphIds[9]); | |
| // now set overflow to visible. | |
| text->overflow(rive::TextOverflow::visible); | |
| artboard->advance(0.0f); | |
| lines = text->orderedLines(); | |
| // 2 lines after we set overflow to visible and advance which updates the | |
| // ordered lines. | |
| REQUIRE(lines.size() == 2); | |
| orderedLine = lines[0]; | |
| glyphCount = 0; | |
| for (auto itr = orderedLine.begin(); itr != orderedLine.end(); ++itr) | |
| { | |
| glyphCount++; | |
| } | |
| // Expect 7 glyphs 'one two' as now 'three' is on line 2. | |
| REQUIRE(glyphCount == 7); | |
| rive::NoOpRenderer renderer; | |
| artboard->draw(&renderer); | |
| rive::GlyphLookup lookup; | |
| lookup.compute(text->unichars(), text->shape()); | |
| REQUIRE(lookup.count(0) == 1); | |
| runObjects[0]->text("a -> b"); | |
| artboard->advance(0.0f); | |
| lookup.compute(text->unichars(), text->shape()); | |
| REQUIRE(lookup.count(0) == 1); // a | |
| REQUIRE(lookup.count(1) == 1); // space | |
| REQUIRE(lookup.count(2) == 2); // - ligates > to -> | |
| REQUIRE(lookup.count(4) == 1); // space | |
| REQUIRE(lookup.count(5) == 1); // b | |
| } | |
| TEST_CASE("fitFontSize shrinks the font to fit the bounds", "[text]") | |
| { | |
| // ellipsis.riv is a fixed-size text box whose content overflows (that's why | |
| // it ellipsizes), making it a good fixture for the fit-by-font-size mode. | |
| auto file = ReadRiveFile("assets/ellipsis.riv"); | |
| auto artboard = file->artboard(); | |
| auto textObjects = artboard->find<rive::Text>(); | |
| REQUIRE(textObjects.size() == 1); | |
| auto text = textObjects[0]; | |
| // Authored size: shape the text as-is (no fitting) and read the run size. | |
| text->overflow(rive::TextOverflow::visible); | |
| artboard->advance(0.0f); | |
| REQUIRE(!text->shape().empty()); | |
| REQUIRE(!text->shape()[0].runs.empty()); | |
| float authoredSize = text->shape()[0].runs[0].size; | |
| REQUIRE(authoredSize > 1.0f); | |
| // With fitFontSize the text is re-shaped at a smaller integer size so that | |
| // it fits, and -- unlike fit -- it is NOT scaled via the transform. | |
| text->overflow(rive::TextOverflow::fitFontSize); | |
| artboard->advance(0.0f); | |
| REQUIRE(!text->shape().empty()); | |
| REQUIRE(!text->shape()[0].runs.empty()); | |
| float fittedSize = text->shape()[0].runs[0].size; | |
| // Shrunk to fit, never below the minimum of 1. | |
| REQUIRE(fittedSize < authoredSize); | |
| REQUIRE(fittedSize >= 1.0f); | |
| // Single run => fontScale = bestInt / authoredSize, so the shaped size is | |
| // exactly an integer. | |
| REQUIRE(fittedSize == std::floor(fittedSize)); | |
| // No post-scaling of the laid-out glyphs. | |
| REQUIRE(text->m_transform.xx() == Approx(1.0f)); | |
| } | |
| TEST_CASE("fitFontSize scales custom line height and letter spacing " | |
| "proportionally", | |
| "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/ellipsis.riv"); | |
| auto artboard = file->artboard(); | |
| auto textObjects = artboard->find<rive::Text>(); | |
| REQUIRE(textObjects.size() == 1); | |
| auto text = textObjects[0]; | |
| // Author absolute custom line height and letter spacing on every style so | |
| // we can verify they track the fitted font size. | |
| const float authoredLineHeight = 40.0f; | |
| const float authoredLetterSpacing = 3.0f; | |
| auto styles = artboard->find<rive::TextStyle>(); | |
| REQUIRE(!styles.empty()); | |
| for (auto style : styles) | |
| { | |
| style->lineHeight(authoredLineHeight); | |
| style->letterSpacing(authoredLetterSpacing); | |
| } | |
| // Authored (unscaled) size: the custom values pass through as-is. | |
| text->overflow(rive::TextOverflow::visible); | |
| artboard->advance(0.0f); | |
| REQUIRE(!text->shape().empty()); | |
| REQUIRE(!text->shape()[0].runs.empty()); | |
| float authoredSize = text->shape()[0].runs[0].size; | |
| REQUIRE(authoredSize > 1.0f); | |
| REQUIRE(text->shape()[0].runs[0].lineHeight == Approx(authoredLineHeight)); | |
| REQUIRE(text->shape()[0].runs[0].letterSpacing == | |
| Approx(authoredLetterSpacing)); | |
| // With fitFontSize the font shrinks; the custom line height and letter | |
| // spacing must shrink by the same multiplier so the layout stays a uniform | |
| // scale of the authored one. | |
| text->overflow(rive::TextOverflow::fitFontSize); | |
| artboard->advance(0.0f); | |
| REQUIRE(!text->shape().empty()); | |
| REQUIRE(!text->shape()[0].runs.empty()); | |
| float fittedSize = text->shape()[0].runs[0].size; | |
| REQUIRE(fittedSize < authoredSize); | |
| float expectedScale = fittedSize / authoredSize; | |
| REQUIRE(text->shape()[0].runs[0].lineHeight == | |
| Approx(authoredLineHeight * expectedScale)); | |
| REQUIRE(text->shape()[0].runs[0].letterSpacing == | |
| Approx(authoredLetterSpacing * expectedScale)); | |
| } | |
| TEST_CASE("fitFontSize scales paragraph spacing proportionally", "[text]") | |
| { | |
| // double_line.riv has multiple paragraphs (explicit new lines), so the | |
| // paragraph gap actually contributes to the vertical layout. | |
| auto file = ReadRiveFile("assets/double_line.riv"); | |
| auto artboard = file->artboard(); | |
| auto textObjects = artboard->find<rive::Text>(); | |
| REQUIRE(textObjects.size() == 1); | |
| auto text = textObjects[0]; | |
| // A large absolute paragraph gap so any (mis)scaling of it is obvious. | |
| text->paragraphSpacing(30.0f); | |
| // A wide fixed box keeps line breaking identical between the two passes | |
| // (only the width affects breaking), so the ordered lines line up 1:1. | |
| text->sizingValue((uint32_t)rive::TextSizing::fixed); | |
| text->width(2000.0f); | |
| text->height(2000.0f); | |
| // Authored pass: visible never shrinks, so this is the natural layout. | |
| text->overflow(rive::TextOverflow::visible); | |
| artboard->advance(0.0f); | |
| // The fixture must actually have multiple paragraphs or the gap is never | |
| // exercised. | |
| REQUIRE(text->shape().size() >= 2); | |
| REQUIRE(!text->shape()[0].runs.empty()); | |
| float authoredSize = text->shape()[0].runs[0].size; | |
| REQUIRE(authoredSize > 1.0f); | |
| std::vector<float> authoredY; | |
| for (const rive::OrderedLine& line : text->orderedLines()) | |
| { | |
| authoredY.push_back(line.y()); | |
| } | |
| REQUIRE(authoredY.size() >= 2); | |
| // Fitted pass: same width (same breaking) but a short box forces the font | |
| // to shrink. Everything absolute -- including the paragraph gap -- must | |
| // shrink with it. | |
| text->height(40.0f); | |
| text->overflow(rive::TextOverflow::fitFontSize); | |
| artboard->advance(0.0f); | |
| REQUIRE(!text->shape()[0].runs.empty()); | |
| float fittedSize = text->shape()[0].runs[0].size; | |
| REQUIRE(fittedSize < authoredSize); | |
| const auto& fittedLines = text->orderedLines(); | |
| REQUIRE(fittedLines.size() == authoredY.size()); | |
| const float scale = fittedSize / authoredSize; | |
| // A true uniform scale: every line's y (which accumulates line heights AND | |
| // the paragraph gap) tracks the font size. Without scaling the paragraph | |
| // gap, lines past the first paragraph would sit too low. | |
| for (size_t i = 0; i < authoredY.size(); i++) | |
| { | |
| REQUIRE(fittedLines[i].y() == Approx(authoredY[i] * scale)); | |
| } | |
| // The vertical span across paragraphs (which is dominated by the gap) must | |
| // itself have shrunk by the same ratio -- guards against a trivial pass at | |
| // y == 0. | |
| float authoredSpan = authoredY.back() - authoredY.front(); | |
| float fittedSpan = fittedLines.back().y() - fittedLines.front().y(); | |
| REQUIRE(authoredSpan > 0.0f); | |
| REQUIRE(fittedSpan < authoredSpan); | |
| REQUIRE(fittedSpan == Approx(authoredSpan * scale)); | |
| } | |
| TEST_CASE("changing paragraph spacing reshapes fitFontSize text", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/double_line.riv"); | |
| auto artboard = file->artboard(); | |
| auto textObjects = artboard->find<rive::Text>(); | |
| REQUIRE(textObjects.size() == 1); | |
| auto text = textObjects[0]; | |
| // A fixed box that forces the multi-paragraph text to shrink to fit. | |
| text->sizingValue((uint32_t)rive::TextSizing::fixed); | |
| text->width(2000.0f); | |
| text->height(150.0f); | |
| text->overflow(rive::TextOverflow::fitFontSize); | |
| text->paragraphSpacing(0.0f); | |
| artboard->advance(0.0f); | |
| REQUIRE(!text->shape().empty()); | |
| REQUIRE(!text->shape()[0].runs.empty()); | |
| float sizeWithoutGap = text->shape()[0].runs[0].size; | |
| REQUIRE(sizeWithoutGap > 1.0f); | |
| // Growing the paragraph gap eats vertical space, so the fitted font must | |
| // shrink. This only happens if the change re-runs the fit search (a | |
| // reshape); a plain paint rebuild would leave the size untouched. | |
| text->paragraphSpacing(60.0f); | |
| artboard->advance(0.0f); | |
| REQUIRE(!text->shape().empty()); | |
| REQUIRE(!text->shape()[0].runs.empty()); | |
| float sizeWithGap = text->shape()[0].runs[0].size; | |
| REQUIRE(sizeWithGap < sizeWithoutGap); | |
| } | |
| static std::vector<rive::Unichar> toUnicode(const char text[]) | |
| { | |
| std::vector<rive::Unichar> codePoints; | |
| const uint8_t* ptr = (const uint8_t*)text; | |
| while (*ptr) | |
| { | |
| codePoints.push_back(rive::UTF::NextUTF8(&ptr)); | |
| } | |
| return codePoints; | |
| } | |
| TEST_CASE("range mapper maps words", "[text]") | |
| { | |
| auto codePoints = toUnicode("one two three four"); | |
| rive::RangeMapper rangeMapper; | |
| rangeMapper.fromWords(codePoints, 0, (uint32_t)codePoints.size()); | |
| REQUIRE(rangeMapper.unitCount() == 4); | |
| } | |
| TEST_CASE("run modifier ranges select runs", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/modifier_to_run.riv"); | |
| auto artboard = file->artboard(); | |
| artboard->advance(0.0f); | |
| rive::NoOpRenderer renderer; | |
| artboard->draw(&renderer); | |
| { | |
| auto characterSelectedText = artboard->find<rive::Text>("Characters"); | |
| REQUIRE(characterSelectedText != nullptr); | |
| REQUIRE(characterSelectedText->haveModifiers()); | |
| REQUIRE(characterSelectedText->modifierGroups().size() == 2); | |
| auto firstModifierGroup = characterSelectedText->modifierGroups()[0]; | |
| REQUIRE(firstModifierGroup->ranges().size() == 1); | |
| auto firstRange = firstModifierGroup->ranges()[0]; | |
| REQUIRE(firstRange->run() != nullptr); | |
| for (int i = 0; i < 4; i++) | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) == 0.0f); | |
| } | |
| // Run from 4-9 got selected. | |
| REQUIRE(firstRange->run()->offset() == 4); | |
| REQUIRE(firstRange->run()->length() == 5); | |
| for (int i = 4; i < 9; i++) | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) != 0.0f); | |
| } | |
| for (int i = 9; i < 26; i++) | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) == 0.0f); | |
| } | |
| } | |
| { | |
| auto wordSelectedText = artboard->find<rive::Text>("Words"); | |
| REQUIRE(wordSelectedText != nullptr); | |
| REQUIRE(wordSelectedText->haveModifiers()); | |
| REQUIRE(wordSelectedText->modifierGroups().size() == 1); | |
| auto firstModifierGroup = wordSelectedText->modifierGroups()[0]; | |
| REQUIRE(firstModifierGroup->ranges().size() == 1); | |
| auto firstRange = firstModifierGroup->ranges()[0]; | |
| REQUIRE(firstRange->run() != nullptr); | |
| for (int i = 0; i < 4; i++) | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) == 0.0f); | |
| } | |
| // Run from 4-34 got selected. | |
| REQUIRE(firstRange->run()->offset() == 4); | |
| auto text = firstRange->run()->text(); | |
| REQUIRE(text.size() == 34); | |
| for (int i = 4; i < 39; i++) | |
| { | |
| if (rive::isWhiteSpace(text[i - 4])) | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) == 0.0f); | |
| } | |
| else | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) != 0.0f); | |
| } | |
| } | |
| for (int i = 39; i < 50; i++) | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) == 0.0f); | |
| } | |
| } | |
| } | |
| TEST_CASE("run modifier ranges select runs with varying text size", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/test_modifier_run.riv"); | |
| auto artboard = file->artboard(); | |
| artboard->advance(0.0f); | |
| rive::NoOpRenderer renderer; | |
| artboard->draw(&renderer); | |
| { | |
| /* | |
| Full text is: | |
| text for first run[UN]line separator[UN]text for second run | |
| [UN] is the united nations flag | |
| the first run is 18 characters long | |
| the second run is 16 characters long | |
| the second run is 20 characters long | |
| */ | |
| auto characterSelectedText = artboard->find<rive::Text>("MultiRunText"); | |
| REQUIRE(characterSelectedText != nullptr); | |
| REQUIRE(characterSelectedText->haveModifiers()); | |
| REQUIRE(characterSelectedText->modifierGroups().size() == 1); | |
| auto firstModifierGroup = characterSelectedText->modifierGroups()[0]; | |
| REQUIRE(firstModifierGroup->ranges().size() == 1); | |
| auto firstRange = firstModifierGroup->ranges()[0]; | |
| REQUIRE(firstRange->run() != nullptr); | |
| for (int i = 0; i < 18; i++) | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) == 0.0f); | |
| } | |
| // // Run from 18-33 got selected. | |
| REQUIRE(firstRange->run()->offset() == 18); | |
| REQUIRE(firstRange->run()->length() == 16); | |
| // We confirm that the size and the length of the text are different and | |
| // they're both correct | |
| REQUIRE(firstRange->run()->text().size() == 22); | |
| for (int i = 18; i < 34; i++) | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) != 0.0f); | |
| } | |
| for (int i = 34; i < 54; i++) | |
| { | |
| REQUIRE(firstModifierGroup->coverage(i) == 0.0f); | |
| } | |
| } | |
| } | |
| TEST_CASE("double new line type works", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/double_line.riv"); | |
| auto artboard = file->artboard(); | |
| auto textObjects = artboard->find<rive::Text>(); | |
| REQUIRE(textObjects.size() == 1); | |
| auto styleObjects = artboard->find<rive::TextStyle>(); | |
| REQUIRE(styleObjects.size() == 1); | |
| auto runObjects = artboard->find<rive::TextValueRun>(); | |
| REQUIRE(runObjects.size() == 9); | |
| artboard->advance(0.0f); | |
| auto text = textObjects[0]; | |
| auto lines = text->orderedLines(); | |
| REQUIRE(lines.size() == 3); | |
| } | |
| TEST_CASE("can load and run opacity modifiers", "[text]") | |
| { | |
| auto file = ReadRiveFile("assets/text_opacity_modifier.riv"); | |
| auto artboard = file->artboardDefault(); | |
| artboard->advance(0.0f); | |
| rive::NoOpRenderer renderer; | |
| artboard->draw(&renderer); | |
| } | |
| TEST_CASE("Zero width spaces are used to break words", "[text]") | |
| { | |
| rive::SerializingFactory silver; | |
| auto file = ReadRiveFile("assets/zero_width_space_line_break.riv", &silver); | |
| auto artboard = file->artboardNamed("main"); | |
| silver.frameSize(artboard->width(), artboard->height()); | |
| auto renderer = silver.makeRenderer(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| auto vmi = file->createViewModelInstance(artboard.get()->viewModelId(), 0); | |
| auto textProp = | |
| vmi->propertyValue("txt")->as<rive::ViewModelInstanceString>(); | |
| stateMachine->bindViewModelInstance(vmi); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| // set a long text that will cause an arbitrary text break looking like this | |
| // 12345678901234567 | |
| // 890 | |
| silver.addFrame(); | |
| std::string text = "12345678901234567890"; | |
| textProp->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| auto zeroWidthSpace = 8203; | |
| char c = static_cast<char>(zeroWidthSpace); | |
| silver.addFrame(); | |
| // Insert a zero width space between the two number sequences | |
| // Now text should look like this | |
| // 1234567890 | |
| // 1234567890 | |
| text.insert(10, 1, c); | |
| textProp->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| CHECK(silver.matches("zero_width_space_line_break")); | |
| } | |
| TEST_CASE("Word joiners are not used to break words", "[text]") | |
| { | |
| rive::SerializingFactory silver; | |
| auto file = ReadRiveFile("assets/word_joiner_test.riv", &silver); | |
| auto artboard = file->artboardNamed("main"); | |
| silver.frameSize(artboard->width(), artboard->height()); | |
| auto renderer = silver.makeRenderer(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| auto vmi = file->createViewModelInstance(artboard.get()->viewModelId(), 0); | |
| auto textProp1 = | |
| vmi->propertyValue("txt1")->as<rive::ViewModelInstanceString>(); | |
| auto textProp2 = | |
| vmi->propertyValue("txt2")->as<rive::ViewModelInstanceString>(); | |
| auto textProp3 = | |
| vmi->propertyValue("txt3")->as<rive::ViewModelInstanceString>(); | |
| auto textProp4 = | |
| vmi->propertyValue("txt4")->as<rive::ViewModelInstanceString>(); | |
| stateMachine->bindViewModelInstance(vmi); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| // set a long text that will cause an arbitrary text break looking like this | |
| // 12345678901234567 | |
| // 890 | |
| silver.addFrame(); | |
| std::string text = "123456789012345678901234567890"; | |
| textProp1->propertyValue(text); | |
| textProp2->propertyValue(text); | |
| textProp3->propertyValue(text); | |
| textProp4->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| // Word joiner character (U+2060) - insert as Unicode code point, not UTF-8 | |
| // bytes | |
| auto wordJoiner = 0x2060; // Unicode code point for word joiner | |
| silver.addFrame(); | |
| // Convert to UTF-8 using the existing UTF::Encode function | |
| uint8_t utf8Buffer[4]; | |
| uint32_t utf8Length = rive::UTF::Encode(utf8Buffer, wordJoiner); | |
| std::string wordJoinerUtf8(reinterpret_cast<char*>(utf8Buffer), utf8Length); | |
| // The last 10 numbers will be joined and the word breaker won't apply | |
| // between them | |
| int ind = 30; | |
| while (ind-- > 21) | |
| { | |
| text.insert(ind, wordJoinerUtf8); | |
| } | |
| textProp1->propertyValue(text); | |
| textProp2->propertyValue(text); | |
| textProp3->propertyValue(text); | |
| textProp4->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| ind++; | |
| // The middle 10 numbers are now also joined | |
| while (ind-- > 11) | |
| { | |
| text.insert(ind, wordJoinerUtf8); | |
| } | |
| textProp1->propertyValue(text); | |
| textProp2->propertyValue(text); | |
| textProp3->propertyValue(text); | |
| textProp4->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| // Two sequences of 60 numbers separated by a new line | |
| text = | |
| "123456789012345678901234567890123456789012345678901234567890|\n12345" | |
| "6789012345678901234567890123456789012345678901234567890\n"; | |
| silver.addFrame(); | |
| textProp1->propertyValue(text); | |
| textProp2->propertyValue(text); | |
| textProp3->propertyValue(text); | |
| textProp4->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| // Adding word joiners that spans both lines. New lines are still respected. | |
| ind = 51; | |
| while (ind-- > 21) | |
| { | |
| text.insert(ind, wordJoinerUtf8); | |
| } | |
| textProp1->propertyValue(text); | |
| textProp2->propertyValue(text); | |
| textProp3->propertyValue(text); | |
| textProp4->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| // Two sequences of 60 numbers separated by a new line | |
| text = | |
| "123456789012345678901234567890123456789012345678901234567890|\n12345" | |
| "6789012345678901234567890123456789012345678901234567890\n"; | |
| silver.addFrame(); | |
| textProp1->propertyValue(text); | |
| textProp2->propertyValue(text); | |
| textProp3->propertyValue(text); | |
| textProp4->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| // Adding multiple word joiners that spans both lines. New lines are still | |
| // respected. Extra joiners are skipped correctly | |
| ind = 51; | |
| while (ind-- > 21) | |
| { | |
| text.insert(ind, wordJoinerUtf8); | |
| text.insert(ind, wordJoinerUtf8); | |
| text.insert(ind, wordJoinerUtf8); | |
| } | |
| textProp1->propertyValue(text); | |
| textProp2->propertyValue(text); | |
| textProp3->propertyValue(text); | |
| textProp4->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| // six sequences of 10 numbers separated with spaces and a new line | |
| text = "1234567890 1234567890 1234567890 1234567890 1234567890 " | |
| "1234567890|\n12345" | |
| "67890 1234567890 1234567890 1234567890 1234567890 1234567890\n"; | |
| silver.addFrame(); | |
| textProp1->propertyValue(text); | |
| textProp2->propertyValue(text); | |
| textProp3->propertyValue(text); | |
| textProp4->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| // Spaces and new lines take precedence over word joins | |
| ind = 51; | |
| while (ind-- > 21) | |
| { | |
| text.insert(ind, wordJoinerUtf8); | |
| text.insert(ind, wordJoinerUtf8); | |
| text.insert(ind, wordJoinerUtf8); | |
| } | |
| textProp1->propertyValue(text); | |
| textProp2->propertyValue(text); | |
| textProp3->propertyValue(text); | |
| textProp4->propertyValue(text); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| CHECK(silver.matches("word_joiner_test")); | |
| } | |
| TEST_CASE("Fit font size", "[text]") | |
| { | |
| rive::SerializingFactory silver; | |
| auto file = ReadRiveFile("assets/fit_font_size_test.riv", &silver); | |
| auto artboard = file->artboardDefault(); | |
| silver.frameSize(artboard->width(), artboard->height()); | |
| auto renderer = silver.makeRenderer(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| auto vmi = file->createViewModelInstance(artboard.get()->viewModelId(), 0); | |
| auto triggerProp = | |
| vmi->propertyValue("trigger")->as<rive::ViewModelInstanceTrigger>(); | |
| stateMachine->bindViewModelInstance(vmi); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| triggerProp->trigger(); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| triggerProp->trigger(); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| triggerProp->trigger(); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| triggerProp->trigger(); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| triggerProp->trigger(); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| silver.addFrame(); | |
| triggerProp->trigger(); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| CHECK(silver.matches("fit_font_size_test")); | |
| } | |
| TEST_CASE("Vertical Trim", "[text]") | |
| { | |
| rive::SerializingFactory silver; | |
| auto file = ReadRiveFile("assets/text_vertical_trim_test.riv", &silver); | |
| auto artboard = file->artboardDefault(); | |
| silver.frameSize(artboard->width(), artboard->height()); | |
| auto renderer = silver.makeRenderer(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| auto vmi = file->createViewModelInstance(artboard.get()->viewModelId(), 0); | |
| stateMachine->bindViewModelInstance(vmi); | |
| stateMachine->advanceAndApply(0.1f); | |
| artboard->draw(renderer.get()); | |
| int frames = (int)(1.0f / 0.16f); | |
| for (int i = 0; i < frames; i++) | |
| { | |
| silver.addFrame(); | |
| stateMachine->advanceAndApply(0.16f); | |
| artboard->draw(renderer.get()); | |
| } | |
| CHECK(silver.matches("text_vertical_trim_test")); | |
| } | |
| // Renders a text/layout matrix for five frames against the named silver. | |
| static void checkTextLayoutSilver(const char* asset, const char* silverName) | |
| { | |
| rive::SerializingFactory silver; | |
| auto file = ReadRiveFile(asset, &silver); | |
| auto artboard = file->artboardDefault(); | |
| REQUIRE(artboard != nullptr); | |
| silver.frameSize(artboard->width(), artboard->height()); | |
| auto renderer = silver.makeRenderer(); | |
| auto stateMachine = artboard->defaultStateMachine(); | |
| auto vmi = file->createDefaultViewModelInstance(artboard.get()); | |
| if (vmi != nullptr) | |
| { | |
| if (stateMachine != nullptr) | |
| { | |
| stateMachine->bindViewModelInstance(vmi); | |
| } | |
| else | |
| { | |
| artboard->bindViewModelInstance(vmi); | |
| } | |
| } | |
| auto advance = [&](float dt) { | |
| if (stateMachine != nullptr) | |
| { | |
| stateMachine->advanceAndApply(dt); | |
| } | |
| else | |
| { | |
| artboard->advance(dt); | |
| } | |
| }; | |
| advance(0.0f); | |
| artboard->draw(renderer.get()); | |
| for (int i = 0; i < 4; i++) | |
| { | |
| silver.addFrame(); | |
| advance(0.016f); | |
| artboard->draw(renderer.get()); | |
| } | |
| CHECK(silver.matches(silverName)); | |
| } | |
| // These two assets are the same scene and differ only in their header's minor | |
| // version (see gen_layout_text_match.py), so any difference between the two | |
| // silvers is attributable to the Text::import gate and nothing else. | |
| // | |
| // Below 7.3, m_layoutSizesBox stays false: an auto-sized text keeps | |
| // content-sized bounds and every overflow mode stays inert, so the six | |
| // overflow modes in each half of the matrix all render identically. | |
| TEST_CASE("Text box keeps its content size before 7.3", "[silver]") | |
| { | |
| checkTextLayoutSilver("assets/layout_text_match.riv", "layout_text_match"); | |
| } | |
| // At 7.3 the box takes the layout's size and the overflow modes engage, so the | |
| // matrix fans out. Rows 15-18 cover verticalAlign middle/bottom over both a | |
| // taller box (minHeight) and a shorter one (maxHeight, where align and line | |
| // culling interact). | |
| TEST_CASE("Text box matches layout-controlled size", "[silver]") | |
| { | |
| checkTextLayoutSilver("assets/layout_text_match_7_3.riv", | |
| "layout_text_match_7_3"); | |
| } | |
| // The two assets are the same scene apart from the version stamp and two | |
| // inert ComponentOrigin children (the editor materialises those on selection; | |
| // neither file carries a pivotOrigin), so they are authored exports rather | |
| // than a generated pair. | |
| TEST_CASE("Middle-aligned hug-layout text before 7.3", "[silver]") | |
| { | |
| checkTextLayoutSilver("assets/layout/text_layout_pre_7_3.riv", | |
| "text_layout_pre_7_3"); | |
| } | |
| TEST_CASE("Middle-aligned hug-layout text at 7.3", "[silver]") | |
| { | |
| checkTextLayoutSilver("assets/layout/text_layout_7_3.riv", | |
| "text_layout_7_3"); | |
| } | |
| TEST_CASE("Fit font size with varying sizes", "[text]") | |
| { | |
| rive::SerializingFactory silver; | |
| auto file = ReadRiveFile("assets/text_fit_test.riv", &silver); | |
| auto artboard = file->artboardDefault(); | |
| silver.frameSize(artboard->width(), artboard->height()); | |
| auto renderer = silver.makeRenderer(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| auto vmi = file->createViewModelInstance(artboard.get()->viewModelId(), 0); | |
| stateMachine->bindViewModelInstance(vmi); | |
| stateMachine->advanceAndApply(0.032f); | |
| artboard->draw(renderer.get()); | |
| int frames = (int)(3.0f / 0.032f); | |
| for (int i = 0; i < frames; i++) | |
| { | |
| silver.addFrame(); | |
| stateMachine->advanceAndApply(0.032f); | |
| artboard->draw(renderer.get()); | |
| } | |
| CHECK(silver.matches("text_fit_test")); | |
| } | |
| TEST_CASE("Text with background color with active feather", "[text]") | |
| { | |
| rive::SerializingFactory silver; | |
| auto file = | |
| ReadRiveFile("assets/text_background_feather_test.riv", &silver); | |
| auto artboard = file->artboardDefault(); | |
| silver.frameSize(artboard->width(), artboard->height()); | |
| auto renderer = silver.makeRenderer(); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| auto vmi = file->createViewModelInstance(artboard.get()->viewModelId(), 0); | |
| stateMachine->bindViewModelInstance(vmi); | |
| stateMachine->advanceAndApply(0.032f); | |
| artboard->draw(renderer.get()); | |
| CHECK(silver.matches("text_background_feather_test")); | |
| } |