| #include "rive/text/text_selection_controller.hpp" | |
| #include "rive/text/text.hpp" | |
| #include "rive/text/text_style_paint.hpp" | |
| #include "rive/text/text_value_run.hpp" | |
| #include "rive/nested_artboard.hpp" | |
| #include "rive_file_reader.hpp" | |
| #include "utils/no_op_renderer.hpp" | |
| #include <algorithm> | |
| #include <limits> | |
| using namespace rive; | |
| namespace | |
| { | |
| // Use the actual drawn lines to locate a source boundary, independently of | |
| // controller hit testing and without reshaping a second copy of the text. | |
| Vec2D pointAt(Text* text, uint32_t offset) | |
| { | |
| GlyphLookup lookup; | |
| lookup.compute(text->unichars(), text->shape()); | |
| SimpleArray<SimpleArray<GlyphLine>> unusedLines; | |
| TextLayoutView view(text->shape(), | |
| unusedLines, | |
| text->orderedLines(), | |
| lookup, | |
| (uint32_t)text->unichars().size()); | |
| CursorPosition position(offset); | |
| position.resolveLine(view); | |
| auto visual = position.clamped(view).visualPosition(view); | |
| REQUIRE(visual.found()); | |
| return text->artboard()->rootTransform( | |
| text->shapeWorldTransform() * | |
| Vec2D(visual.x(), (visual.top() + visual.bottom()) / 2)); | |
| } | |
| void setText(Text* text, const std::string& value) | |
| { | |
| auto runs = text->runs(); | |
| REQUIRE(!runs.empty()); | |
| runs.front()->text(value); | |
| for (size_t i = 1; i < runs.size(); i++) | |
| { | |
| runs[i]->text(""); | |
| } | |
| } | |
| struct TextSelectionScene | |
| { | |
| rcp<File> file = ReadRiveFile("assets/new_text.riv"); | |
| std::unique_ptr<ArtboardInstance> artboard = file->artboard()->instance(); | |
| std::vector<Text*> texts = artboard->find<Text>(); | |
| TextSelectionController selection; | |
| TextSelectionScene() | |
| { | |
| REQUIRE(texts.size() >= 4); | |
| const char* values[] = {"Introduction", | |
| "Rive supports interactive graphics.", | |
| "Learn more", | |
| "Outside the selected range"}; | |
| for (size_t i = 0; i < 4; i++) | |
| { | |
| auto* text = texts[i]; | |
| setText(text, values[i]); | |
| text->x(0); | |
| text->y((float)i * 200); | |
| text->originX(0); | |
| text->originY(0); | |
| text->sizingValue((uint32_t)TextSizing::autoWidth); | |
| text->overflowValue((uint32_t)TextOverflow::visible); | |
| for (auto* style : text->textStylePaints()) | |
| { | |
| style->fontSize(24); | |
| } | |
| } | |
| artboard->advance(0); | |
| for (size_t i = 0; i < 4; i++) | |
| { | |
| REQUIRE(selection.add(texts[i])); | |
| } | |
| } | |
| }; | |
| class CountingRenderer : public NoOpRenderer | |
| { | |
| public: | |
| size_t paths = 0; | |
| void drawPath(RenderPath*, RenderPaint*) override { paths++; } | |
| }; | |
| } // namespace | |
| TEST_CASE("regular text drag spans objects and gaps in reading order", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto& selection = scene.selection; | |
| auto& text = scene.texts; | |
| auto start = pointAt(text[0], 5); | |
| auto end = pointAt(text[2], 5); | |
| REQUIRE(selection.pointerDown(text[0], start, 7)); | |
| CHECK(selection.selectedText().empty()); | |
| REQUIRE(selection.pointerMove(Vec2D(end.x + 20, (start.y + end.y) / 2), 7)); | |
| REQUIRE(selection.pointerUp(end, 7)); | |
| CHECK_FALSE(selection.isDragging()); | |
| CHECK(selection.selectedText() == | |
| "duction\nRive supports interactive graphics.\nLearn"); | |
| CHECK_FALSE(selection.selectionRects(text[0]).empty()); | |
| CHECK_FALSE(selection.selectionRects(text[1]).empty()); | |
| CHECK_FALSE(selection.selectionRects(text[2]).empty()); | |
| CHECK(selection.selectionRects(text[3]).empty()); | |
| REQUIRE(selection.pointerDown(text[2], end)); | |
| REQUIRE(selection.pointerUp(start)); | |
| CHECK(selection.selectedText() == | |
| "duction\nRive supports interactive graphics.\nLearn"); | |
| } | |
| TEST_CASE("regular text selection builds its lookup only when needed", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto& selection = scene.selection; | |
| auto* a = scene.texts[0]; | |
| auto* b = scene.texts[1]; | |
| CountingRenderer renderer; | |
| for (auto* text : scene.texts) | |
| CHECK(selection.lookupBuildCount(text) == 0); | |
| selection.setRange(a, 0, 3); | |
| selection.setRange(b, 0, 0); | |
| b->draw(&renderer); | |
| CHECK(selection.selectionRects(b).empty()); | |
| CHECK(selection.lookupBuildCount(b) == 0); | |
| REQUIRE_FALSE(selection.selectionRects(a).empty()); | |
| CHECK(selection.lookupBuildCount(a) == 1); | |
| SelectionStyle style; | |
| style.highlightColor(0x80FF8800); | |
| style.cornerRadius(6); | |
| selection.style(&style); | |
| a->markPaintDirty(); | |
| scene.artboard->advance(0); | |
| selection.setRange(a, 0, 3); | |
| REQUIRE_FALSE(selection.selectionRects(a).empty()); | |
| CHECK(selection.lookupBuildCount(a) == 1); | |
| a->textStylePaints().front()->fontSize(32); | |
| scene.artboard->advance(0); | |
| CHECK(selection.lookupBuildCount(a) == 1); | |
| REQUIRE_FALSE(selection.selectionRects(a).empty()); | |
| CHECK(selection.lookupBuildCount(a) == 2); | |
| setText(a, "Changed source"); | |
| scene.artboard->advance(0); | |
| a->draw(&renderer); | |
| CHECK(selection.selectedText().empty()); | |
| CHECK(selection.lookupBuildCount(a) == 2); | |
| } | |
| TEST_CASE( | |
| "regular text selection includes the last character and object separators", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto& s = scene.selection; | |
| auto a = scene.texts[0]; | |
| auto b = scene.texts[1]; | |
| REQUIRE(s.select(a, 0, a, UINT32_MAX)); | |
| CHECK(s.selectedText() == "Introduction"); | |
| CHECK(s.selection(a).last().codePointIndex() == 12); | |
| REQUIRE(s.select(a, 12, b, 0)); | |
| CHECK(s.selectedText() == "\n"); | |
| REQUIRE(s.select(b, 0, a, 12)); | |
| CHECK(s.selectedText() == "\n"); | |
| REQUIRE(s.select(a, 12, a, 12)); | |
| CHECK_FALSE(s.hasSelection()); | |
| CHECK(s.selectedText().empty()); | |
| CHECK(s.selectionRects(a).empty()); | |
| } | |
| TEST_CASE( | |
| "regular text selection copies runs exactly without visual wrap breaks", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto text = scene.texts[0]; | |
| // The fixture contains multiple independently styled runs. | |
| auto rich = std::find_if(scene.texts.begin(), | |
| scene.texts.end(), | |
| [](Text* t) { return t->runs().size() >= 2; }); | |
| REQUIRE(rich != scene.texts.end()); | |
| text = *rich; | |
| setText(text, "Hello "); | |
| text->runs()[1]->text("styled world\nNext paragraph"); | |
| text->sizingValue((uint32_t)TextSizing::autoHeight); | |
| text->width(100); | |
| scene.artboard->advance(0); | |
| TextSelectionController separate; | |
| scene.selection.remove(text); | |
| REQUIRE(separate.add(text)); | |
| REQUIRE(text->orderedLines().size() > 2); | |
| separate.selectAll(); | |
| CHECK(separate.selectedText() == "Hello styled world\nNext paragraph"); | |
| CHECK_FALSE(separate.selectionRects(text).empty()); | |
| } | |
| TEST_CASE("regular text highlights draw within the existing Text render pass", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| CountingRenderer before; | |
| scene.artboard->draw(&before); | |
| REQUIRE(scene.selection.select(scene.texts[0], 3, scene.texts[2], 5)); | |
| CountingRenderer selected; | |
| scene.artboard->draw(&selected); | |
| CHECK(selected.paths == before.paths + 3); | |
| scene.selection.clear(); | |
| CountingRenderer after; | |
| scene.artboard->draw(&after); | |
| CHECK(after.paths == before.paths); | |
| } | |
| TEST_CASE( | |
| "regular text selection survives relayout and whole object transforms", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto text = scene.texts[1]; | |
| auto& s = scene.selection; | |
| REQUIRE(s.select(text, 0, text, UINT32_MAX)); | |
| auto value = s.selectedText(); | |
| text->sizingValue((uint32_t)TextSizing::autoHeight); | |
| text->width(130); | |
| text->originX(0.5f); | |
| text->rotation(0.35f); | |
| text->scaleX(1.5f); | |
| scene.artboard->advance(0); | |
| CHECK(s.selectedText() == value); | |
| REQUIRE(text->orderedLines().size() > 1); | |
| REQUIRE(s.pointerDown(text, pointAt(text, 5))); | |
| REQUIRE(s.pointerUp(pointAt(text, 13))); | |
| CHECK(s.selectedText() == "supports"); | |
| CHECK_FALSE(s.selectionRects(text).empty()); | |
| } | |
| TEST_CASE("regular text source changes invalidate the shared range and capture", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto& s = scene.selection; | |
| REQUIRE(s.pointerDown(scene.texts[0], pointAt(scene.texts[0], 1))); | |
| REQUIRE(s.pointerMove(pointAt(scene.texts[2], 5))); | |
| REQUIRE(s.hasSelection()); | |
| setText(scene.texts[1], "Updated from data binding"); | |
| scene.artboard->advance(0); | |
| CHECK_FALSE(s.hasSelection()); | |
| CHECK_FALSE(s.isDragging()); | |
| CHECK(s.selectedText().empty()); | |
| CHECK(s.selectionRects(scene.texts[0]).empty()); | |
| CHECK_FALSE(s.pointerMove(pointAt(scene.texts[2], 6))); | |
| } | |
| TEST_CASE("regular text selection keeps one pointer and handles cancellation", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto text = scene.texts[0]; | |
| auto& s = scene.selection; | |
| REQUIRE(s.pointerDown(text, pointAt(text, 1), 9)); | |
| CHECK_FALSE(s.pointerDown(text, pointAt(text, 8), 10)); | |
| CHECK_FALSE(s.pointerMove(pointAt(text, 8), 10)); | |
| REQUIRE(s.pointerMove(pointAt(text, 5), 9)); | |
| CHECK(s.selectedText() == "ntro"); | |
| CHECK_FALSE(s.pointerCancel(10)); | |
| REQUIRE( | |
| s.pointerMove(Vec2D(std::numeric_limits<float>::quiet_NaN(), 0), 9)); | |
| CHECK(s.selectedText() == "ntro"); | |
| REQUIRE(s.pointerCancel(9)); | |
| CHECK_FALSE(s.isDragging()); | |
| CHECK(s.selectedText().empty()); | |
| CHECK_FALSE(s.pointerDown(nullptr, pointAt(text, 0))); | |
| } | |
| TEST_CASE("regular text accepts selection commands without editing", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto& s = scene.selection; | |
| auto text = scene.texts[0]; | |
| REQUIRE(s.select(text, 1, text, 4)); | |
| CHECK_FALSE(s.keyInput(Key::backspace, KeyModifiers::none, true)); | |
| CHECK_FALSE(s.keyInput(Key::deleteKey, KeyModifiers::none, true)); | |
| CHECK_FALSE(s.keyInput(Key::v, KeyModifiers::meta, true)); | |
| CHECK_FALSE(s.keyInput(Key::x, KeyModifiers::ctrl, true)); | |
| CHECK(s.selectedText() == "ntr"); | |
| REQUIRE(s.keyInput(Key::a, KeyModifiers::ctrl, true)); | |
| CHECK( | |
| s.selectedText() == | |
| "Introduction\nRive supports interactive graphics.\nLearn more\nOutside the selected range"); | |
| REQUIRE(s.keyInput(Key::escape, KeyModifiers::none, true)); | |
| CHECK(s.selectedText().empty()); | |
| CHECK(text->runs()[0]->text() == "Introduction"); | |
| } | |
| TEST_CASE("regular text selection ownership survives either destruction order", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto text = scene.texts[0]; | |
| CHECK_FALSE(scene.selection.add(text)); | |
| TextSelectionController other; | |
| CHECK_FALSE(other.add(text)); | |
| scene.selection.remove(text); | |
| REQUIRE(other.add(text)); | |
| other.selectAll(); | |
| REQUIRE(other.hasSelection()); | |
| scene.artboard.reset(); | |
| CHECK_FALSE(other.hasSelection()); | |
| CHECK(other.selectedText().empty()); | |
| CHECK(scene.selection.selectedText().empty()); | |
| } | |
| TEST_CASE("regular text selection supports inline object separators", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto a = scene.texts[0]; | |
| auto b = scene.texts[1]; | |
| scene.selection.remove(a); | |
| scene.selection.remove(b); | |
| TextSelectionController inlineSelection; | |
| REQUIRE(inlineSelection.add(a)); | |
| REQUIRE(inlineSelection.add(b, " ")); | |
| REQUIRE(inlineSelection.select(a, 5, b, 4)); | |
| CHECK(inlineSelection.selectedText() == "duction Rive"); | |
| } | |
| TEST_CASE("regular text selection copies Unicode source offsets", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto text = scene.texts[0]; | |
| setText(text, "A\xC3\xA9\xF0\x9F\x98\x80Z"); | |
| scene.artboard->advance(0); | |
| REQUIRE(scene.selection.select(text, 1, text, 3)); | |
| CHECK(scene.selection.selectedText() == "\xC3\xA9\xF0\x9F\x98\x80"); | |
| REQUIRE(scene.selection.select(text, 0, text, UINT32_MAX)); | |
| CHECK(scene.selection.selectedText() == "A\xC3\xA9\xF0\x9F\x98\x80Z"); | |
| } | |
| TEST_CASE("empty and hidden text cannot leave stale selection highlights", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| auto text = scene.texts[0]; | |
| setText(text, ""); | |
| scene.artboard->advance(0); | |
| CHECK_FALSE(scene.selection.select(text, 0, text, 1)); | |
| CHECK(scene.selection.selectionRects(text).empty()); | |
| scene.selection.selectAll(); | |
| CHECK( | |
| scene.selection.selectedText() == | |
| "Rive supports interactive graphics.\nLearn more\nOutside the selected range"); | |
| scene.texts[1]->opacity(0); | |
| scene.artboard->advance(0); | |
| CHECK(scene.selection.selectedText().empty()); | |
| CHECK(scene.selection.selectionRects(scene.texts[2]).empty()); | |
| } | |
| TEST_CASE("destroying a selection controller unregisters its Text objects", | |
| "[text_selection]") | |
| { | |
| auto file = ReadRiveFile("assets/hello_world.riv"); | |
| auto artboard = file->artboard(); | |
| artboard->advance(0); | |
| auto text = artboard->find<Text>()[0]; | |
| { | |
| TextSelectionController selection; | |
| REQUIRE(selection.add(text)); | |
| selection.selectAll(); | |
| CHECK(selection.selectedText() == "Hello World!"); | |
| } | |
| TextSelectionController next; | |
| REQUIRE(next.add(text)); | |
| CHECK(next.selectedText().empty()); | |
| text->overflowValue((uint32_t)TextOverflow::ellipsis); | |
| artboard->advance(0); | |
| CHECK_FALSE(next.select(text, 0, text, 5)); | |
| } | |
| TEST_CASE("regular text selection spans actual nested artboard instances", | |
| "[text_selection]") | |
| { | |
| auto file = ReadRiveFile("assets/runtime_nested_text_runs.riv"); | |
| auto root = file->artboard("ArtboardA")->instance(); | |
| auto firstRun = root->getTextRun("ArtboardBRun", "ArtboardB-1"); | |
| auto middleRun = | |
| root->getTextRun("ArtboardCRun", "ArtboardB-1/ArtboardC-1"); | |
| auto lastRun = root->getTextRun("ArtboardBRun", "ArtboardB-2"); | |
| REQUIRE(firstRun != nullptr); | |
| REQUIRE(middleRun != nullptr); | |
| REQUIRE(lastRun != nullptr); | |
| firstRun->text("Introduction"); | |
| middleRun->text("Nested middle"); | |
| lastRun->text("Learn more"); | |
| root->advance(0); | |
| auto first = firstRun->textComponent(); | |
| auto middle = middleRun->textComponent(); | |
| auto last = lastRun->textComponent(); | |
| REQUIRE(first != last); | |
| // These are two instances of the same authored text component. | |
| CHECK(first->artboard() != last->artboard()); | |
| CHECK(first->name() == last->name()); | |
| TextSelectionController selection; | |
| REQUIRE(selection.add(first)); | |
| REQUIRE(selection.add(middle)); | |
| REQUIRE(selection.add(last)); | |
| REQUIRE(selection.pointerDown(first, pointAt(first, 5))); | |
| REQUIRE(selection.pointerUp(pointAt(last, 5))); | |
| CHECK(selection.selectedText() == "duction\nNested middle\nLearn"); | |
| CHECK_FALSE(selection.selectionRects(middle).empty()); | |
| } | |
| TEST_CASE("Selectable discovery exposes stable safe host tokens", | |
| "[text_selection]") | |
| { | |
| TextSelectionScene scene; | |
| scene.selection.synchronize(scene.artboard.get()); | |
| REQUIRE(scene.selection.targetIds().empty()); | |
| auto* a = scene.texts[0]; | |
| auto* b = scene.texts[1]; | |
| a->drawableFlags((uint16_t)DrawableFlag::Selectable); | |
| b->drawableFlags((uint16_t)DrawableFlag::Selectable); | |
| scene.selection.synchronize(scene.artboard.get()); | |
| auto ids = scene.selection.targetIds(); | |
| REQUIRE(ids.size() == 2); | |
| REQUIRE(scene.selection.target(ids[0]) == a); | |
| scene.selection.synchronize(scene.artboard.get()); | |
| REQUIRE(scene.selection.targetIds() == ids); | |
| REQUIRE(scene.selection.sourceText(a) == "Introduction"); | |
| uint32_t offset = 0; | |
| float distance = 0; | |
| REQUIRE(scene.selection.hitTest(a, pointAt(a, 5), false, offset, distance)); | |
| REQUIRE(offset == 5); | |
| REQUIRE(distance == 0); | |
| REQUIRE_FALSE(scene.selection.hitTest(a, | |
| Vec2D(10000, 10000), | |
| false, | |
| offset, | |
| distance)); | |
| REQUIRE(scene.selection | |
| .hitTest(a, Vec2D(10000, 10000), true, offset, distance)); | |
| scene.selection.setRange(a, 5, 12); | |
| scene.selection.setRange(b, 0, 4); | |
| REQUIRE(scene.selection.hasSelection()); | |
| REQUIRE(scene.selection.selectedText() == "duction\nRive"); | |
| REQUIRE_FALSE(scene.selection.selectionRects(a).empty()); | |
| REQUIRE_FALSE(scene.selection.selectionRects(b).empty()); | |
| a->drawableFlags(0); | |
| scene.selection.synchronize(scene.artboard.get()); | |
| REQUIRE(scene.selection.target(ids[0]) == nullptr); | |
| REQUIRE_FALSE(scene.selection.hasSelection()); | |
| a->drawableFlags((uint16_t)DrawableFlag::Selectable); | |
| scene.selection.synchronize(scene.artboard.get()); | |
| REQUIRE(scene.selection.target(ids[0]) == nullptr); | |
| REQUIRE(scene.selection.targetIds().size() == 2); | |
| } |