blob: a50cbee102b2c3e7ea457c40417f5b4c73079737 [file]
#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);
}