| #ifdef WITH_RIVE_TEXT | |
| #include "rive/text/cursor.hpp" | |
| #include "rive/text/font_hb.hpp" | |
| #include "rive/layout/layout_component_style.hpp" | |
| #include "rive/layout_component.hpp" | |
| #include "rive/constraints/scrolling/scroll_constraint.hpp" | |
| #include "rive/text/text_input.hpp" | |
| #include "rive/text/text_input_drawable.hpp" | |
| #include "rive/text/text_input_text.hpp" | |
| #include "rive/text/text_input_cursor.hpp" | |
| #include "rive/text/text_input_selection.hpp" | |
| #include "rive/text/text_input_selected_text.hpp" | |
| #include "rive/animation/state_machine_instance.hpp" | |
| #include "rive/nested_artboard.hpp" | |
| #include "rive/focus_data.hpp" | |
| #include "rive/input/focusable.hpp" | |
| #include "rive/input/focus_manager.hpp" | |
| #include "rive/input/focus_node.hpp" | |
| #include "rive_testing.hpp" | |
| #include "utils/no_op_factory.hpp" | |
| #include "utils/no_op_renderer.hpp" | |
| #include "rive_file_reader.hpp" | |
| #include "utils/serializing_factory.hpp" | |
| using namespace rive; | |
| TEST_CASE("file with text input loads correctly", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| CHECK(file != nullptr); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard->objects<TextInput>().size() == 1); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| CHECK(textInput->children<TextInputDrawable>().size() == 3); | |
| CHECK(textInput->children<TextInputText>().size() == 1); | |
| CHECK(textInput->children<TextInputSelection>().size() == 1); | |
| CHECK(textInput->children<TextInputCursor>().size() == 1); | |
| // Keeping this check for now. Might need it back in the future | |
| CHECK(textInput->children<TextInputSelectedText>().size() == 0); | |
| } | |
| TEST_CASE("file with text input renders correctly", "[silver]") | |
| { | |
| SerializingFactory silver; | |
| auto file = ReadRiveFile("assets/text_input.riv", &silver); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| silver.frameSize(artboard->width(), artboard->height()); | |
| artboard->advance(0.0f); | |
| auto renderer = silver.makeRenderer(); | |
| artboard->draw(renderer.get()); | |
| CHECK(silver.matches("text_input")); | |
| } | |
| TEST_CASE("text input keyInput handles arrow keys", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| // Set some initial text | |
| textInput->rawTextInput()->text("hello world"); | |
| textInput->rawTextInput()->cursor(Cursor::zero()); | |
| artboard->advance(0.0f); | |
| // Test right arrow key | |
| bool handled = textInput | |
| ->keyInput(Key::right, KeyModifiers::none, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->cursor().start().codePointIndex() == 1); | |
| // Test left arrow key | |
| handled = textInput->keyInput(Key::left, KeyModifiers::none, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->cursor().start().codePointIndex() == 0); | |
| // Test down arrow key (moves to end on single line) | |
| handled = textInput->keyInput(Key::down, KeyModifiers::none, true, false); | |
| CHECK(handled == true); | |
| // Test up arrow key (moves to start on single line) | |
| handled = textInput->keyInput(Key::up, KeyModifiers::none, true, false); | |
| CHECK(handled == true); | |
| } | |
| TEST_CASE("text input keyInput handles backspace and delete", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| // Set some initial text | |
| textInput->rawTextInput()->text("hello"); | |
| textInput->rawTextInput()->cursor( | |
| Cursor::collapsed(CursorPosition(3))); // "hel|lo" | |
| artboard->advance(0.0f); | |
| // Test backspace | |
| bool handled = textInput->keyInput( | |
| Key::backspace, | |
| KeyModifiers::none, | |
| true, | |
| false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == "helo"); | |
| // Test delete key | |
| textInput->rawTextInput()->cursor( | |
| Cursor::collapsed(CursorPosition(2))); // "he|lo" | |
| handled = textInput | |
| ->keyInput(Key::deleteKey, KeyModifiers::none, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == "heo"); | |
| } | |
| TEST_CASE("text input keyInput handles undo/redo", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| // Set some initial text and make changes | |
| textInput->rawTextInput()->text(""); | |
| textInput->rawTextInput()->cursor(Cursor::zero()); | |
| textInput->rawTextInput()->insert("hello"); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->text() == "hello"); | |
| // Test undo with system modifier (meta on macOS/Linux, ctrl on Windows) | |
| // On non-Windows, non-Emscripten, systemModifier() returns | |
| // KeyModifiers::meta | |
| #if !defined(RIVE_WINDOWS) && !defined(__EMSCRIPTEN__) | |
| bool handled = textInput->keyInput(Key::z, KeyModifiers::meta, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == ""); | |
| // Insert again and test redo | |
| textInput->rawTextInput()->insert("world"); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->text() == "world"); | |
| // Undo | |
| handled = textInput->keyInput(Key::z, KeyModifiers::meta, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == ""); | |
| // Redo with shift+meta | |
| handled = textInput->keyInput( | |
| Key::z, | |
| KeyModifiers::meta | KeyModifiers::shift, | |
| true, | |
| false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == "world"); | |
| #endif | |
| } | |
| TEST_CASE( | |
| "text input keyInput returns false for unhandled keys", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| artboard->advance(0.0f); | |
| // Test unhandled key | |
| bool handled = textInput | |
| ->keyInput(Key::escape, KeyModifiers::none, true, false); | |
| CHECK(handled == false); | |
| // Test that key release (isPressed=false) returns false | |
| handled = textInput->keyInput(Key::right, KeyModifiers::none, false, false); | |
| CHECK(handled == false); | |
| } | |
| TEST_CASE( | |
| "text input keyInput handles modifier keys for cursor boundary", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| // Set text with multiple words | |
| textInput->rawTextInput()->text("one two three"); | |
| textInput->rawTextInput()->cursor(Cursor::zero()); | |
| artboard->advance(0.0f); | |
| // Test word boundary with alt modifier | |
| bool handled = textInput | |
| ->keyInput(Key::right, KeyModifiers::alt, true, false); | |
| CHECK(handled == true); | |
| // Should jump to end of "one" | |
| CHECK(textInput->rawTextInput()->cursor().start().codePointIndex() == 3); | |
| // Test line boundary with meta modifier (from current position) | |
| // First reset cursor to start | |
| textInput->rawTextInput()->cursor(Cursor::zero()); | |
| handled = textInput->keyInput(Key::right, KeyModifiers::meta, true, false); | |
| CHECK(handled == true); | |
| // Should jump to end of line | |
| CHECK(textInput->rawTextInput()->cursor().start().codePointIndex() == 13); | |
| // Test sub-word boundary with alt+ctrl modifier | |
| textInput->rawTextInput()->text("oneTwo threeF"); | |
| textInput->rawTextInput()->cursor(Cursor::zero()); | |
| artboard->advance(0.0f); | |
| handled = textInput->keyInput( | |
| Key::right, | |
| KeyModifiers::alt | KeyModifiers::ctrl, | |
| true, | |
| false); | |
| CHECK(handled == true); | |
| // Should jump to sub-word boundary (camelCase) | |
| CHECK(textInput->rawTextInput()->cursor().start().codePointIndex() == 3); | |
| } | |
| TEST_CASE("text input keyInput handles shift for selection", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| textInput->rawTextInput()->cursor(Cursor::zero()); | |
| artboard->advance(0.0f); | |
| // Move right with shift should select | |
| bool handled = textInput | |
| ->keyInput(Key::right, KeyModifiers::shift, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->cursor().start().codePointIndex() == 0); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 1); | |
| // Continue selecting | |
| handled = textInput->keyInput(Key::right, KeyModifiers::shift, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->cursor().start().codePointIndex() == 0); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 2); | |
| } | |
| TEST_CASE("text input keyInput handles select all", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| textInput->rawTextInput()->cursor(Cursor::zero()); | |
| artboard->advance(0.0f); | |
| // The system modifier is ctrl on Windows and meta elsewhere, matching | |
| // TextInput::systemModifier(). | |
| #if defined(RIVE_WINDOWS) | |
| KeyModifiers systemModifier = KeyModifiers::ctrl; | |
| #else | |
| KeyModifiers systemModifier = KeyModifiers::meta; | |
| #endif | |
| // Cmd/Ctrl+A selects the entire buffer. | |
| bool handled = textInput->keyInput(Key::a, systemModifier, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->cursor().start().codePointIndex() == 0); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 11); | |
| // A plain 'a' is not handled here so it falls through to be typed. | |
| handled = textInput->keyInput(Key::a, KeyModifiers::none, true, false); | |
| CHECK(handled == false); | |
| } | |
| TEST_CASE("text input keyInput handles home and end", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| // Start with a collapsed cursor in the middle of the line. | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(5))); | |
| artboard->advance(0.0f); | |
| // End moves the cursor to the end of the line. | |
| bool handled = textInput->keyInput(Key::end, KeyModifiers::none, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->cursor().isCollapsed()); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 11); | |
| // Home moves the cursor to the start of the line. | |
| handled = textInput->keyInput(Key::home, KeyModifiers::none, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->cursor().isCollapsed()); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 0); | |
| // Shift+End extends the selection from the current position to line end. | |
| handled = textInput->keyInput(Key::end, KeyModifiers::shift, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->cursor().hasSelection()); | |
| CHECK(textInput->rawTextInput()->cursor().start().codePointIndex() == 0); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 11); | |
| } | |
| TEST_CASE("raw text input selectLine selects the visual line", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| textInput->rawTextInput()->text("hello\nworld"); | |
| artboard->advance(0.0f); | |
| // Cursor on the second line ("world", codepoints 6..10). | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(8))); | |
| textInput->rawTextInput()->selectLine(); | |
| CHECK(textInput->rawTextInput()->cursor().hasSelection()); | |
| CHECK(textInput->rawTextInput()->cursor().first().codePointIndex() == 6); | |
| CHECK(textInput->rawTextInput()->cursor().last().codePointIndex() == 11); | |
| // Cursor on the first line ("hello", codepoints 0..4). | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(2))); | |
| textInput->rawTextInput()->selectLine(); | |
| CHECK(textInput->rawTextInput()->cursor().hasSelection()); | |
| CHECK(textInput->rawTextInput()->cursor().first().codePointIndex() == 0); | |
| CHECK(textInput->rawTextInput()->cursor().last().codePointIndex() == 5); | |
| } | |
| TEST_CASE("text input selectWord and selectLine wrappers", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| artboard->advance(0.0f); | |
| // selectWord selects the word under the cursor. | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(2))); | |
| textInput->selectWord(); | |
| CHECK(textInput->rawTextInput()->cursor().first().codePointIndex() == 0); | |
| CHECK(textInput->rawTextInput()->cursor().last().codePointIndex() == 5); | |
| // selectLine selects the whole (single) line. | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(8))); | |
| textInput->selectLine(); | |
| CHECK(textInput->rawTextInput()->cursor().first().codePointIndex() == 0); | |
| CHECK(textInput->rawTextInput()->cursor().last().codePointIndex() == 11); | |
| } | |
| // A text input drag is torn down when TextInputListenerGroup::processEvent sees | |
| // the click phase go from down to out. Collapsing the artboard cancels the | |
| // phase before processEvent runs, so the group has to end the drag as part of | |
| // cancelling -- otherwise the input stays in its dragging state for good. | |
| TEST_CASE("collapsing an artboard ends a text input drag", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| stateMachine->advanceAndApply(0.0f); | |
| stateMachine->pointerDown(Vec2D(8.0f, 8.0f)); | |
| REQUIRE(textInput->isDragging() == true); | |
| artboard->scaleX(0.0f); | |
| artboard->scaleY(0.0f); | |
| artboard->advance(0.0f); | |
| stateMachine->pointerMove(Vec2D(20.0f, 8.0f)); | |
| REQUIRE(textInput->isDragging() == false); | |
| } | |
| TEST_CASE( | |
| "text input double and triple click select word and line", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| if (stateMachine == nullptr) | |
| { | |
| return; | |
| } | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| if (textInput == nullptr) | |
| { | |
| return; | |
| } | |
| textInput->rawTextInput()->text("hello world"); | |
| stateMachine->advanceAndApply(0.0f); | |
| // Click near the top-left where the first word renders. | |
| Vec2D clickPosition(8.0f, 8.0f); | |
| auto pressRelease = [&]() { | |
| stateMachine->pointerDown(clickPosition); | |
| stateMachine->pointerUp(clickPosition); | |
| }; | |
| // Two rapid clicks should select the word under the pointer. | |
| pressRelease(); // single | |
| pressRelease(); // double | |
| stateMachine->advanceAndApply(0.0f); | |
| if (!textInput->rawTextInput()->cursor().hasSelection()) | |
| { | |
| // The click did not land on the text input's hit area in this asset | |
| // layout; skip the pointer-driven assertions rather than fail | |
| // spuriously. The word/line selection paths are covered | |
| // deterministically by the selectWord/selectLine tests above. | |
| return; | |
| } | |
| auto wordEnd = textInput->rawTextInput()->cursor().last().codePointIndex(); | |
| CHECK(wordEnd > textInput->rawTextInput()->cursor().first().codePointIndex()); | |
| // A third rapid click selects the (visual) line, spanning at least the | |
| // word. | |
| pressRelease(); // triple | |
| stateMachine->advanceAndApply(0.0f); | |
| CHECK(textInput->rawTextInput()->cursor().hasSelection()); | |
| CHECK(textInput->rawTextInput()->cursor().last().codePointIndex() >= wordEnd); | |
| } | |
| TEST_CASE("text input textInput method inserts text", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| textInput->rawTextInput()->text(""); | |
| textInput->rawTextInput()->cursor(Cursor::zero()); | |
| artboard->advance(0.0f); | |
| // Test textInput method | |
| bool handled = textInput->textInput("hello"); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == "hello"); | |
| // Insert more text | |
| handled = textInput->textInput(" world"); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == "hello world"); | |
| } | |
| TEST_CASE("text input max limits reach the raw text input", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| CHECK(textInput->maxCharacters() == 0); | |
| CHECK(textInput->maxLines() == 0); | |
| CHECK(textInput->rawTextInput()->maxCharacters() == 0); | |
| CHECK(textInput->rawTextInput()->maxLines() == 0); | |
| textInput->text(""); | |
| textInput->maxCharacters(4); | |
| textInput->maxLines(2); | |
| CHECK(textInput->rawTextInput()->maxCharacters() == 4); | |
| CHECK(textInput->rawTextInput()->maxLines() == 2); | |
| artboard->advance(0.0f); | |
| // Typing is capped, and a refused insert still counts as handled. | |
| CHECK(textInput->textInput("hello")); | |
| CHECK(textInput->text() == "hell"); | |
| CHECK(textInput->textInput("!")); | |
| CHECK(textInput->text() == "hell"); | |
| // Enter is held to the line limit too. | |
| textInput->maxCharacters(0); | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(4))); | |
| CHECK(textInput->keyInput(Key::enter, KeyModifiers::none, true, false)); | |
| CHECK(textInput->text() == "hell\n"); | |
| CHECK(textInput->keyInput(Key::enter, KeyModifiers::none, true, false)); | |
| CHECK(textInput->text() == "hell\n"); | |
| CHECK(textInput->rawTextInput()->text() == "hell\n"); | |
| // Text set on the component is never trimmed. | |
| textInput->maxCharacters(2); | |
| textInput->text("much longer\nthan\nthe limits"); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->text() == "much longer\nthan\nthe limits"); | |
| } | |
| TEST_CASE("text input hugs a layout in the legacy hug encoding", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| LayoutComponent* layout = contentSizingLayout(textInput->parent()); | |
| REQUIRE(layout != nullptr); | |
| REQUIRE(layout->style() != nullptr); | |
| REQUIRE(layout->isLeaf()); | |
| auto* style = layout->style(); | |
| // Older files store a hug as fixed scales on both axes of an intrinsically | |
| // sized leaf, with no point or percent width. | |
| style->layoutWidthScaleType((uint8_t)LayoutScaleType::fixed); | |
| style->layoutHeightScaleType((uint8_t)LayoutScaleType::fixed); | |
| style->intrinsicallySizedValue(true); | |
| style->widthUnitsValue((uint8_t)YGUnitAuto); | |
| CHECK(layout->hugsContentWidth()); | |
| textInput->controlSize( | |
| Vec2D(120.0f, 40.0f), | |
| LayoutScaleType::fixed, | |
| LayoutScaleType::fixed, | |
| LayoutDirection::ltr); | |
| CHECK(textInput->rawTextInput()->hugsWidth()); | |
| // A stored point width is a real fixed width. | |
| style->widthUnitsValue((uint8_t)YGUnitPoint); | |
| CHECK_FALSE(layout->hugsContentWidth()); | |
| textInput->controlSize( | |
| Vec2D(120.0f, 40.0f), | |
| LayoutScaleType::fixed, | |
| LayoutScaleType::fixed, | |
| LayoutDirection::ltr); | |
| CHECK_FALSE(textInput->rawTextInput()->hugsWidth()); | |
| } | |
| TEST_CASE("text input hugs its width only in a hugging layout", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| artboard->advance(0.0f); | |
| CHECK_FALSE(textInput->rawTextInput()->hugsWidth()); | |
| textInput->controlSize( | |
| Vec2D(120.0f, 40.0f), | |
| LayoutScaleType::hug, | |
| LayoutScaleType::hug, | |
| LayoutDirection::ltr); | |
| CHECK(textInput->rawTextInput()->hugsWidth()); | |
| textInput->controlSize( | |
| Vec2D(120.0f, 40.0f), | |
| LayoutScaleType::fill, | |
| LayoutScaleType::hug, | |
| LayoutDirection::ltr); | |
| CHECK_FALSE(textInput->rawTextInput()->hugsWidth()); | |
| } | |
| TEST_CASE( | |
| "a refused keystroke leaves a single line input's source text alone", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| // A single line input displays the source's line break as a space. | |
| textInput->multiline(false); | |
| textInput->text("a\nb"); | |
| textInput->maxCharacters(3); | |
| artboard->advance(0.0f); | |
| REQUIRE(textInput->rawTextInput()->text() == "a b"); | |
| // Full, so nothing goes in, and the source keeps its line break. | |
| CHECK(textInput->textInput("x")); | |
| CHECK(textInput->rawTextInput()->text() == "a b"); | |
| CHECK(textInput->text() == "a\nb"); | |
| } | |
| TEST_CASE( | |
| "text input maxLines lets a full field's lines grow wider", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| REQUIRE(textInput->multiline()); | |
| textInput->text("123\n123\n123"); | |
| textInput->maxLines(3); | |
| artboard->advance(0.0f); | |
| artboard->advance(0.0f); | |
| REQUIRE(textInput->rawTextInput()->shape().lineCount() == 3); | |
| // The input hugs its text, so the first line is as wide as the field is | |
| // laid out; typing at its end has to grow the field, not wrap. | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(3))); | |
| CHECK(textInput->textInput("4")); | |
| CHECK(textInput->text() == "1234\n123\n123"); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->shape().lineCount() == 3); | |
| } | |
| TEST_CASE( | |
| "text input multiline toggles line breaks in displayed text", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| textInput->text("line1\nline2"); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->text() == "line1\nline2"); | |
| textInput->multiline(false); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->text() == "line1 line2"); | |
| textInput->multiline(true); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->text() == "line1\nline2"); | |
| } | |
| TEST_CASE("text input alignValue drives the raw text input", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| // Default is left. | |
| CHECK(textInput->alignValue() == 0); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->align() == TextAlign::left); | |
| textInput->alignValue((uint32_t)TextAlign::right); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->align() == TextAlign::right); | |
| textInput->alignValue((uint32_t)TextAlign::center); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->align() == TextAlign::center); | |
| } | |
| TEST_CASE("text input aligns its text within the field", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| textInput->text("hi"); | |
| artboard->advance(0.0f); | |
| float alignWidth = textInput->rawTextInput()->alignWidth(); | |
| // The multiline field wraps to its layout width, so we have something to | |
| // align within. | |
| REQUIRE(alignWidth > 0.0f); | |
| AABB leftBounds = textInput->localBounds(); | |
| REQUIRE(leftBounds.width() < alignWidth); | |
| CHECK(leftBounds.minX == 0.0f); | |
| textInput->alignValue((uint32_t)TextAlign::right); | |
| artboard->advance(0.0f); | |
| AABB rightBounds = textInput->localBounds(); | |
| CHECK(rightBounds.minX == Approx(alignWidth - leftBounds.width())); | |
| // Alignment moves the text, it doesn't resize it. | |
| CHECK(rightBounds.width() == Approx(leftBounds.width())); | |
| textInput->alignValue((uint32_t)TextAlign::center); | |
| artboard->advance(0.0f); | |
| AABB centerBounds = textInput->localBounds(); | |
| CHECK(centerBounds.minX == Approx((alignWidth - leftBounds.width()) / 2.0f)); | |
| CHECK(centerBounds.width() == Approx(leftBounds.width())); | |
| } | |
| TEST_CASE( | |
| "text input strips inserted line breaks when single line", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| textInput->text(""); | |
| textInput->multiline(false); | |
| artboard->advance(0.0f); | |
| bool handled = textInput->textInput("a\nb\r\nc\rd"); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == "a b c d"); | |
| textInput->multiline(true); | |
| artboard->advance(0.0f); | |
| CHECK(textInput->rawTextInput()->text() == "a b c d"); | |
| } | |
| TEST_CASE("text input keyInput handles enter key", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| textInput->text("hello"); | |
| textInput->multiline(true); | |
| textInput->rawTextInput()->cursor( | |
| Cursor::collapsed(CursorPosition(3))); // "hel|lo" | |
| artboard->advance(0.0f); | |
| // Enter inserts a newline at the cursor in multiline mode. | |
| bool handled = textInput | |
| ->keyInput(Key::enter, KeyModifiers::none, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == "hel\nlo"); | |
| CHECK(textInput->text() == "hel\nlo"); | |
| // Enter is not handled and does not mutate text in single-line mode. | |
| textInput->text("hello"); | |
| textInput->multiline(false); | |
| textInput->rawTextInput()->cursor( | |
| Cursor::collapsed(CursorPosition(3))); // "hel|lo" | |
| artboard->advance(0.0f); | |
| handled = textInput->keyInput(Key::enter, KeyModifiers::none, true, false); | |
| CHECK(handled == false); | |
| CHECK(textInput->rawTextInput()->text() == "hello"); | |
| CHECK(textInput->text() == "hello"); | |
| } | |
| TEST_CASE( | |
| "text input selectionRadiusChanged updates raw text input", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| CHECK(textInput != nullptr); | |
| // Set selection radius | |
| textInput->selectionRadius(5.0f); | |
| // The selectionRadiusChanged callback should be invoked | |
| // Just verify it doesn't crash and the value is set | |
| CHECK(textInput->selectionRadius() == 5.0f); | |
| } | |
| TEST_CASE( | |
| "state machine keyInput and textInput forward to text input", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| if (stateMachine == nullptr) | |
| { | |
| // Skip if no state machine | |
| return; | |
| } | |
| // Advance to initialize | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| if (textInput == nullptr) | |
| { | |
| // Skip if no text input found | |
| return; | |
| } | |
| // Focus the text input (required for text/key input to be handled) | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| stateMachine->setFocus(focusData); | |
| // Clear text first | |
| textInput->rawTextInput()->text(""); | |
| textInput->rawTextInput()->cursor(Cursor::zero()); | |
| // Test textInput through state machine | |
| bool handled = stateMachine->textInput("typed text"); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == "typed text"); | |
| // Test keyInput through state machine (backspace) | |
| handled = stateMachine | |
| ->keyInput(Key::backspace, KeyModifiers::none, true, false); | |
| CHECK(handled == true); | |
| CHECK(textInput->rawTextInput()->text() == "typed tex"); | |
| // With focus cleared there is no target, so the state machine reports the | |
| // events as unhandled and the text is left alone. | |
| stateMachine->clearFocus(); | |
| CHECK(stateMachine->textInput("more") == false); | |
| CHECK( | |
| stateMachine->keyInput(Key::backspace, KeyModifiers::none, true, false) == | |
| false); | |
| CHECK(textInput->rawTextInput()->text() == "typed tex"); | |
| } | |
| TEST_CASE("losing focus clears the text input selection", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| auto cursor = artboard->objects<TextInputCursor>().first(); | |
| REQUIRE(cursor != nullptr); | |
| // Unfocused: no cursor is drawn. | |
| CHECK(textInput->isFocused() == false); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| stateMachine->setFocus(focusData); | |
| CHECK(textInput->isFocused() == true); | |
| CHECK(cursor->localClockwisePath() != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| textInput->rawTextInput()->selectAll(); | |
| CHECK(textInput->rawTextInput()->cursor().hasSelection()); | |
| stateMachine->clearFocus(); | |
| CHECK(textInput->rawTextInput()->cursor().isCollapsed()); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 11); | |
| CHECK(textInput->isFocused() == false); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| } | |
| TEST_CASE("a focused text input reports that it accepts text", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto focusManager = stateMachine->focusManager(); | |
| REQUIRE(focusManager != nullptr); | |
| CHECK(focusManager->primaryFocusAcceptsText() == false); | |
| // The focus target for a text input is its FocusData child; the query | |
| // sees through to the TextInput parent. | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| stateMachine->setFocus(focusData); | |
| CHECK(artboard->objects<TextInput>().first()->isFocused() == true); | |
| CHECK(focusManager->primaryFocusAcceptsText() == true); | |
| stateMachine->clearFocus(); | |
| CHECK(focusManager->primaryFocusAcceptsText() == false); | |
| } | |
| TEST_CASE("the text input cursor blinks while focused", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| CHECK(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| auto cursor = artboard->objects<TextInputCursor>().first(); | |
| REQUIRE(cursor != nullptr); | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| // Unfocused, the caret never draws no matter how much time passes. | |
| stateMachine->advanceAndApply(0.6f); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| // Focusing shows the caret, which then toggles every half second. | |
| stateMachine->setFocus(focusData); | |
| CHECK(cursor->localClockwisePath() != nullptr); | |
| stateMachine->advanceAndApply(0.5f); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| stateMachine->advanceAndApply(0.5f); | |
| CHECK(cursor->localClockwisePath() != nullptr); | |
| // Typing restarts the cycle so the caret stays solid while editing. | |
| stateMachine->advanceAndApply(0.4f); | |
| stateMachine->textInput("a"); | |
| stateMachine->advanceAndApply(0.2f); | |
| CHECK(cursor->localClockwisePath() != nullptr); | |
| // Moving the caret restarts it too. | |
| stateMachine->advanceAndApply(0.4f); | |
| stateMachine->keyInput(Key::left, KeyModifiers::none, true, false); | |
| stateMachine->advanceAndApply(0.2f); | |
| CHECK(cursor->localClockwisePath() != nullptr); | |
| // Blurring hides it again. | |
| stateMachine->clearFocus(); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| } | |
| // The field the text aligns within is the viewport's content box. Padding on | |
| // the viewport is space the text can't occupy, so it has to come off the align | |
| // width -- otherwise centered/right text is pushed into the padding. | |
| TEST_CASE("viewport padding comes off the alignment box", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| artboard->advance(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| float unpaddedWidth = textInput->rawTextInput()->alignWidth(); | |
| float unpaddedHeight = textInput->rawTextInput()->alignHeight(); | |
| CHECK(unpaddedWidth > 0.0f); | |
| CHECK(unpaddedHeight > 0.0f); | |
| // TextInput -> Text Container -> Scroll Content -> Viewport. | |
| auto viewportComponent = textInput->parent()->parent()->parent(); | |
| REQUIRE(viewportComponent != nullptr); | |
| REQUIRE(viewportComponent->is<LayoutComponent>()); | |
| auto viewport = viewportComponent->as<LayoutComponent>(); | |
| auto style = viewport->style(); | |
| REQUIRE(style != nullptr); | |
| style->paddingLeft(12.0f); | |
| style->paddingRight(8.0f); | |
| style->paddingTop(5.0f); | |
| style->paddingBottom(3.0f); | |
| artboard->advance(0.0f); | |
| CHECK(viewport->paddingLeft() == Approx(12.0f)); | |
| CHECK( | |
| textInput->rawTextInput()->alignWidth() == | |
| Approx(unpaddedWidth - 12.0f - 8.0f)); | |
| CHECK( | |
| textInput->rawTextInput()->alignHeight() == | |
| Approx(unpaddedHeight - 5.0f - 3.0f)); | |
| } | |
| // A dropped or suspended frame can hand us an advance spanning several blink | |
| // phases. Toggling once regardless would leave the caret in the wrong phase | |
| // for every even number of them. | |
| TEST_CASE("the caret blink accounts for every elapsed phase", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto cursor = artboard->objects<TextInputCursor>().first(); | |
| REQUIRE(cursor != nullptr); | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| stateMachine->setFocus(focusData); | |
| REQUIRE(cursor->localClockwisePath() != nullptr); | |
| // Two whole phases: back to visible, not hidden. | |
| stateMachine->advanceAndApply(1.0f); | |
| CHECK(cursor->localClockwisePath() != nullptr); | |
| // Three whole phases: hidden. | |
| stateMachine->advanceAndApply(1.5f); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| // Four whole phases leaves it where it was. | |
| stateMachine->advanceAndApply(2.0f); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| // And the leftover remainder still carries into the next phase: 0.3 after | |
| // the 2.0 above puts us 0.3 into a phase, so 0.2 more flips it. | |
| stateMachine->advanceAndApply(0.3f); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| stateMachine->advanceAndApply(0.2f); | |
| CHECK(cursor->localClockwisePath() != nullptr); | |
| } | |
| // The caret reads its blink phase at draw time, so a toggle has to mark the | |
| // artboard changed or a host that only redraws on didChange() never shows it. | |
| TEST_CASE("the caret blink marks the artboard changed", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| NoOpRenderer renderer; | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| auto cursor = artboard->objects<TextInputCursor>().first(); | |
| REQUIRE(cursor != nullptr); | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| stateMachine->setFocus(focusData); | |
| stateMachine->advanceAndApply(0.0f); | |
| artboard->draw(&renderer); | |
| REQUIRE(!artboard->didChange()); | |
| // Mid-phase nothing moved, so there is nothing to redraw. | |
| stateMachine->advanceAndApply(0.2f); | |
| CHECK(!artboard->didChange()); | |
| // Hiding and showing the caret both count as a change. | |
| stateMachine->advanceAndApply(0.3f); | |
| REQUIRE(cursor->localClockwisePath() == nullptr); | |
| CHECK(artboard->didChange()); | |
| artboard->draw(&renderer); | |
| stateMachine->advanceAndApply(0.5f); | |
| REQUIRE(cursor->localClockwisePath() != nullptr); | |
| CHECK(artboard->didChange()); | |
| artboard->draw(&renderer); | |
| // An even number of phases in one advance leaves the caret where it was. | |
| stateMachine->advanceAndApply(1.0f); | |
| REQUIRE(cursor->localClockwisePath() != nullptr); | |
| CHECK(!artboard->didChange()); | |
| // A selection hides the caret whatever the blink phase, so its toggles | |
| // change nothing on screen. | |
| textInput->rawTextInput()->text("hello world"); | |
| textInput->rawTextInput()->selectAll(); | |
| stateMachine->advanceAndApply(0.0f); | |
| artboard->draw(&renderer); | |
| REQUIRE(!artboard->didChange()); | |
| stateMachine->advanceAndApply(0.5f); | |
| CHECK(!artboard->didChange()); | |
| } | |
| TEST_CASE( | |
| "obscured text input keeps selected text off the clipboard", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| textInput->rawTextInput()->text("hunter2"); | |
| artboard->advance(0.0f); | |
| textInput->rawTextInput()->selectAll(); | |
| std::string selected; | |
| REQUIRE(textInput->selectedText(selected)); | |
| CHECK(selected == "hunter2"); | |
| // A reused non-empty string must come back cleared. | |
| textInput->obscured(true); | |
| selected = "stale"; | |
| CHECK(textInput->selectedText(selected)); | |
| CHECK(selected.empty()); | |
| } | |
| namespace | |
| { | |
| class SelectionAncestor : public Focusable | |
| { | |
| public: | |
| bool keyInput(Key, KeyModifiers, bool, bool) override { return false; } | |
| bool textInput(const std::string&) override { return false; } | |
| void focused() override {} | |
| void blurred() override {} | |
| bool selectedText(std::string& outText) const override | |
| { | |
| outText = "ancestor selection"; | |
| return true; | |
| } | |
| }; | |
| } // namespace | |
| TEST_CASE( | |
| "obscured text input stops selection lookup at itself", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| SelectionAncestor ancestor; | |
| auto ancestorNode = make_rcp<FocusNode>(&ancestor); | |
| auto inputNode = make_rcp<FocusNode>(static_cast<Focusable*>(textInput)); | |
| ancestorNode->addChild(inputNode); | |
| FocusManager manager; | |
| manager.setFocus(inputNode); | |
| textInput->rawTextInput()->text("hunter2"); | |
| artboard->advance(0.0f); | |
| textInput->rawTextInput()->selectAll(); | |
| CHECK(manager.selectedText() == "hunter2"); | |
| // With no selection the lookup still bubbles to the ancestor. | |
| textInput->rawTextInput()->clearSelection(); | |
| CHECK(manager.selectedText() == "ancestor selection"); | |
| // Obscured, the input terminates the lookup even without a selection. | |
| textInput->rawTextInput()->selectAll(); | |
| textInput->obscured(true); | |
| CHECK(manager.selectedText().empty()); | |
| manager.setFocus(nullptr); | |
| } | |
| TEST_CASE( | |
| "state machine selectedText reports the focused input's selection", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| stateMachine->advanceAndApply(0.0f); | |
| // Nothing focused yet. | |
| CHECK(stateMachine->selectedText().empty()); | |
| stateMachine->setFocus(focusData); | |
| textInput->rawTextInput()->clearSelection(); | |
| CHECK(stateMachine->selectedText().empty()); | |
| textInput->rawTextInput()->selectAll(); | |
| CHECK(stateMachine->selectedText() == "hello world"); | |
| // A partial selection reports just that range (2nd-4th characters). | |
| textInput->rawTextInput()->cursor( | |
| Cursor(CursorPosition(1), CursorPosition(4))); | |
| CHECK(stateMachine->selectedText() == "ell"); | |
| // An obscured field keeps its selection off the clipboard. | |
| textInput->obscured(true); | |
| CHECK(stateMachine->selectedText().empty()); | |
| textInput->obscured(false); | |
| stateMachine->clearFocus(); | |
| CHECK(stateMachine->selectedText().empty()); | |
| } | |
| TEST_CASE("tab traversal into a text input selects all", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| // Target focus places a caret, not a selection. | |
| stateMachine->setFocus(focusData); | |
| CHECK(textInput->isFocused()); | |
| CHECK(textInput->rawTextInput()->cursor().isCollapsed()); | |
| stateMachine->clearFocus(); | |
| REQUIRE(stateMachine->focusNext()); | |
| CHECK(textInput->isFocused()); | |
| CHECK(textInput->rawTextInput()->selectedText() == "hello world"); | |
| } | |
| TEST_CASE("focus without a press puts the caret after the text", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| // Text set from outside (the file's own, a data bind) leaves the caret at | |
| // the start. | |
| textInput->text("hello world"); | |
| stateMachine->advanceAndApply(0.0f); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 0); | |
| // A focus action, as on a state's entry: typing continues the text. | |
| stateMachine->queueFocusTarget(focusData); | |
| stateMachine->advanceAndApply(0.0f); | |
| REQUIRE(textInput->isFocused()); | |
| auto caret = textInput->rawTextInput()->cursor(); | |
| CHECK(caret.isCollapsed()); | |
| CHECK(caret.end().codePointIndex() == 11); | |
| textInput->textInput("!"); | |
| CHECK(textInput->text() == "hello world!"); | |
| // Focus coming back finds the caret where the user left it. | |
| textInput->keyInput(Key::left, KeyModifiers::none, true, false); | |
| stateMachine->clearFocus(); | |
| stateMachine->setFocus(focusData); | |
| REQUIRE(textInput->isFocused()); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 11); | |
| // New text from outside puts it after the text again. | |
| stateMachine->clearFocus(); | |
| textInput->text("hello"); | |
| stateMachine->setFocus(focusData); | |
| REQUIRE(textInput->isFocused()); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 5); | |
| // Text from outside while focused resets the caret, but once the user | |
| // moves it, it is theirs to come back to. | |
| textInput->text("hello world"); | |
| textInput->keyInput(Key::right, KeyModifiers::none, true, false); | |
| stateMachine->clearFocus(); | |
| stateMachine->setFocus(focusData); | |
| REQUIRE(textInput->isFocused()); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 1); | |
| } | |
| TEST_CASE("selectAllOnFocus selects on target focus too", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| textInput->selectAllOnFocus(true); | |
| auto focusData = artboard->objects<FocusData>().first(); | |
| REQUIRE(focusData != nullptr); | |
| stateMachine->setFocus(focusData); | |
| CHECK(textInput->rawTextInput()->selectedText() == "hello world"); | |
| // Losing focus drops the selection, so the next focus selects again. | |
| stateMachine->clearFocus(); | |
| CHECK(textInput->rawTextInput()->cursor().isCollapsed()); | |
| stateMachine->setFocus(focusData); | |
| CHECK(textInput->rawTextInput()->selectedText() == "hello world"); | |
| } | |
| TEST_CASE( | |
| "selectAllOnFocus press selects all, later press places the caret", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| textInput->rawTextInput()->text("hello world"); | |
| textInput->selectAllOnFocus(true); | |
| stateMachine->advanceAndApply(0.0f); | |
| // Press inside the input wherever the asset lays it out. | |
| AABB bounds; | |
| REQUIRE(textInput->worldBounds(bounds)); | |
| Vec2D pressPosition(bounds.left() + 8.0f, bounds.top() + 8.0f); | |
| stateMachine->pointerDown(pressPosition); | |
| stateMachine->pointerUp(pressPosition); | |
| stateMachine->advanceAndApply(0.0f); | |
| REQUIRE(textInput->isFocused()); | |
| CHECK(textInput->rawTextInput()->selectedText() == "hello world"); | |
| // Already focused, a press places the caret and a drag extends from it. | |
| // Far enough from the first press not to count as a double click. | |
| Vec2D secondPress(bounds.left() + 40.0f, bounds.top() + 8.0f); | |
| stateMachine->pointerDown(secondPress); | |
| stateMachine->advanceAndApply(0.0f); | |
| CHECK(textInput->rawTextInput()->cursor().isCollapsed()); | |
| stateMachine->pointerMove(pressPosition); | |
| stateMachine->advanceAndApply(0.0f); | |
| CHECK(textInput->rawTextInput()->cursor().hasSelection()); | |
| CHECK(textInput->rawTextInput()->selectedText() != "hello world"); | |
| stateMachine->pointerUp(pressPosition); | |
| } | |
| // The widest glyph run drawn, selected or not: a selection can move the | |
| // glyphs to their own path. | |
| static float drawnTextWidth(RawTextInput* raw) | |
| { | |
| return std::max( | |
| raw->textPath()->rawPath()->bounds().width(), | |
| raw->selectedTextPath()->rawPath()->bounds().width()); | |
| } | |
| TEST_CASE("text shaped ahead of the update still draws", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| textInput->text("i"); | |
| artboard->advance(0.0f); | |
| float narrow = drawnTextWidth(textInput->rawTextInput()); | |
| REQUIRE(narrow > 0.0f); | |
| // Selecting shapes the new text at once, before the update that builds | |
| // what is drawn; that update must still build it from the new shape. | |
| textInput->text("mmmmmmmm"); | |
| textInput->rawTextInput()->selectAll(); | |
| artboard->advance(0.0f); | |
| CHECK(drawnTextWidth(textInput->rawTextInput()) > narrow * 4.0f); | |
| } | |
| TEST_CASE("the caret hides while text is selected", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input.riv"); | |
| auto artboard = file->artboardNamed("Text Input - Multiline"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| auto cursor = artboard->objects<TextInputCursor>().first(); | |
| REQUIRE(cursor != nullptr); | |
| stateMachine->setFocus(artboard->objects<FocusData>().first()); | |
| textInput->rawTextInput()->text("hello world"); | |
| stateMachine->advanceAndApply(0.0f); | |
| // A range is selected: the selection shows and the caret does not, as | |
| // platform text fields draw it. | |
| textInput->rawTextInput()->cursor( | |
| Cursor(CursorPosition(0), CursorPosition(5))); | |
| stateMachine->advanceAndApply(0.0f); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| // Collapsed again: the caret is back. | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(5))); | |
| stateMachine->advanceAndApply(0.0f); | |
| CHECK(cursor->localClockwisePath() != nullptr); | |
| } | |
| // text_input_tray.riv: each field box spans x 32..432, its text container | |
| // filling the box; SingleLine's sits at y 136..184, Multiline's at y 136..276. | |
| TEST_CASE( | |
| "a press past the end of the text puts the caret at the end", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input_tray.riv"); | |
| for (const char* name : {"SingleLine", "Multiline"}) | |
| { | |
| INFO(name); | |
| auto artboard = file->artboardNamed(name); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| textInput->text("Hello world"); | |
| stateMachine->advanceAndApply(0.0f); | |
| // A press inside the text places the caret there. | |
| stateMachine->pointerDown(Vec2D(70.0f, 160.0f)); | |
| stateMachine->pointerUp(Vec2D(70.0f, 160.0f)); | |
| stateMachine->advanceAndApply(0.0f); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() < 11); | |
| // A press in the field to the right of the text goes to the end. | |
| stateMachine->pointerDown(Vec2D(420.0f, 160.0f)); | |
| stateMachine->pointerUp(Vec2D(420.0f, 160.0f)); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto caret = textInput->rawTextInput()->cursor(); | |
| CHECK(caret.start().codePointIndex() == 11); | |
| CHECK(caret.end().codePointIndex() == 11); | |
| } | |
| } | |
| TEST_CASE( | |
| "undo reaches the first edit and stops at the initial text", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input_tray.riv"); | |
| auto artboard = file->artboardNamed("SingleLine"); | |
| REQUIRE(artboard != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| artboard->advance(0.0f); | |
| auto undo = [&]() { | |
| textInput->keyInput( | |
| Key::z, | |
| KeyModifiers::meta | KeyModifiers::ctrl, | |
| true, | |
| false); | |
| }; | |
| // A field that starts empty: the first edit can be undone. | |
| textInput->textInput("abc"); | |
| undo(); | |
| CHECK(textInput->text() == ""); | |
| // Text set from outside (a data bind) starts a new history: undo takes | |
| // back the edits after it, never the text itself. | |
| textInput->text("Hello"); | |
| artboard->advance(0.0f); | |
| textInput->textInput("!"); | |
| CHECK(textInput->text() == "!Hello"); | |
| undo(); | |
| CHECK(textInput->text() == "Hello"); | |
| undo(); | |
| CHECK(textInput->text() == "Hello"); | |
| } | |
| // The library component hugs the text on both axes, so the input's own slot | |
| // is only as big as the text. The whole viewport is the field: a press in its | |
| // empty part still reaches the input and lands the caret at the end. The | |
| // fixture (source: assets/rml/text_input_viewport.rml) is that hierarchy with | |
| // a fixed 300pt-wide viewport; its font covers uppercase only. | |
| TEST_CASE( | |
| "a press anywhere in the viewport reaches the text input", | |
| "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input_viewport.riv"); | |
| for (const char* name : {"SingleLine", "Multiline"}) | |
| { | |
| INFO(name); | |
| auto artboard = file->artboardNamed(name); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| // TextInput -> Text Container -> Scroll Content (+Scroll) -> Viewport. | |
| auto container = textInput->parent(); | |
| REQUIRE(container != nullptr); | |
| REQUIRE(container->is<LayoutComponent>()); | |
| auto content = container->parent(); | |
| REQUIRE(content != nullptr); | |
| REQUIRE(content->is<LayoutComponent>()); | |
| REQUIRE(content->as<LayoutComponent>() | |
| ->constraints() | |
| .front() | |
| ->is<ScrollConstraint>()); | |
| auto viewportComponent = content->parent(); | |
| REQUIRE(viewportComponent != nullptr); | |
| REQUIRE(viewportComponent->is<LayoutComponent>()); | |
| auto viewport = viewportComponent->as<LayoutComponent>(); | |
| textInput->text("HI"); | |
| stateMachine->advanceAndApply(0.0f); | |
| REQUIRE(viewport->layoutWidth() == Approx(300.0f)); | |
| // The far corner of the viewport, well clear of the text's slot. | |
| Vec2D farCorner = viewport->worldTransform() * | |
| Vec2D( | |
| viewport->layoutWidth() - 2.0f, | |
| viewport->layoutHeight() - 2.0f); | |
| Mat2D inverseContainer; | |
| REQUIRE(container->as<LayoutComponent>()->worldTransform().invert( | |
| &inverseContainer)); | |
| REQUIRE_FALSE(container->as<LayoutComponent>()->localBounds().contains( | |
| inverseContainer * farCorner)); | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(0))); | |
| stateMachine->pointerDown(farCorner); | |
| stateMachine->pointerUp(farCorner); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto caret = textInput->rawTextInput()->cursor(); | |
| CHECK(caret.start().codePointIndex() == 2); | |
| CHECK(caret.end().codePointIndex() == 2); | |
| CHECK(textInput->isFocused()); | |
| // Just outside the viewport the press misses the input. | |
| textInput->rawTextInput()->cursor(Cursor::collapsed(CursorPosition(0))); | |
| Vec2D outside = viewport->worldTransform() * | |
| Vec2D( | |
| viewport->layoutWidth() + 5.0f, | |
| viewport->layoutHeight() * 0.5f); | |
| stateMachine->pointerDown(outside); | |
| stateMachine->pointerUp(outside); | |
| stateMachine->advanceAndApply(0.0f); | |
| CHECK(textInput->rawTextInput()->cursor().end().codePointIndex() == 0); | |
| } | |
| } | |
| static void press( | |
| StateMachineInstance* stateMachine, | |
| Vec2D position, | |
| PointerButton button = PointerButton::primary) | |
| { | |
| stateMachine->pointerDown(position, 0, button); | |
| stateMachine->pointerUp(position, 0, button); | |
| stateMachine->advanceAndApply(0.0f); | |
| } | |
| // A primary press that lands outside the focused field takes its focus away, | |
| // as a platform text field blurs on an outside click. Presses inside the | |
| // field, secondary-button presses and presses outside the artboard are the | |
| // edges. | |
| TEST_CASE("a press outside a focused text input blurs it", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input_viewport.riv"); | |
| auto artboard = file->artboardNamed("SingleLine"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| auto textInput = artboard->objects<TextInput>().first(); | |
| REQUIRE(textInput != nullptr); | |
| auto cursor = artboard->objects<TextInputCursor>().first(); | |
| REQUIRE(cursor != nullptr); | |
| auto viewport = textInput->parent()->parent()->parent(); | |
| REQUIRE(viewport->is<LayoutComponent>()); | |
| auto viewportLayout = viewport->as<LayoutComponent>(); | |
| stateMachine->advanceAndApply(0.0f); | |
| // Inside the field's empty area, past the field but inside the artboard, | |
| // and off the artboard altogether. | |
| Vec2D inField = viewportLayout->worldTransform() * | |
| Vec2D( | |
| viewportLayout->layoutWidth() - 2.0f, | |
| viewportLayout->layoutHeight() * 0.5f); | |
| Vec2D pastField = viewportLayout->worldTransform() * | |
| Vec2D( | |
| viewportLayout->layoutWidth() + 20.0f, | |
| viewportLayout->layoutHeight() * 0.5f); | |
| REQUIRE(artboard->bounds().contains(pastField)); | |
| Vec2D offArtboard(-10.0f, -10.0f); | |
| press(stateMachine.get(), inField); | |
| CHECK(textInput->isFocused()); | |
| CHECK(cursor->localClockwisePath() != nullptr); | |
| press(stateMachine.get(), pastField); | |
| CHECK_FALSE(textInput->isFocused()); | |
| CHECK(cursor->localClockwisePath() == nullptr); | |
| // A second press in the field focuses it again, and a press in the field | |
| // while focused keeps it. | |
| press(stateMachine.get(), inField); | |
| CHECK(textInput->isFocused()); | |
| press(stateMachine.get(), inField); | |
| CHECK(textInput->isFocused()); | |
| // Only the primary button blurs: a right click elsewhere leaves the caret | |
| // where it is, as a context menu would. | |
| press(stateMachine.get(), pastField, PointerButton::secondary); | |
| CHECK(textInput->isFocused()); | |
| press(stateMachine.get(), offArtboard); | |
| CHECK_FALSE(textInput->isFocused()); | |
| } | |
| // Focus lives on the root's manager, so the root resolves the press even when | |
| // the focused field sits inside a nested artboard: a press elsewhere in the | |
| // host blurs it, and a press on a sibling field moves focus in one step. | |
| TEST_CASE("a press in the host blurs a nested text input", "[text_input]") | |
| { | |
| auto file = ReadRiveFile("assets/text_input_viewport.riv"); | |
| auto artboard = file->artboardNamed("Host"); | |
| REQUIRE(artboard != nullptr); | |
| auto stateMachine = artboard->stateMachineAt(0); | |
| REQUIRE(stateMachine != nullptr); | |
| stateMachine->advanceAndApply(0.0f); | |
| auto hosts = artboard->nestedArtboards(); | |
| REQUIRE(hosts.size() == 2); | |
| auto fieldPoint = [](NestedArtboard* host) { | |
| auto instance = host->artboardInstance(); | |
| auto textInput = instance->objects<TextInput>().first(); | |
| auto viewport = textInput->parent()->parent()->parent(); | |
| auto viewportLayout = viewport->as<LayoutComponent>(); | |
| Vec2D local = viewportLayout->worldTransform() * | |
| Vec2D( | |
| viewportLayout->layoutWidth() - 2.0f, | |
| viewportLayout->layoutHeight() * 0.5f); | |
| return host->worldTransform() * local; | |
| }; | |
| auto first = hosts[0]->artboardInstance()->objects<TextInput>().first(); | |
| auto second = hosts[1]->artboardInstance()->objects<TextInput>().first(); | |
| REQUIRE(first != nullptr); | |
| REQUIRE(second != nullptr); | |
| Vec2D firstField = fieldPoint(hosts[0]); | |
| Vec2D secondField = fieldPoint(hosts[1]); | |
| // Clear of both nested artboards, inside the host. | |
| Vec2D hostEmpty(700.0f, 50.0f); | |
| REQUIRE(artboard->bounds().contains(hostEmpty)); | |
| press(stateMachine.get(), firstField); | |
| CHECK(first->isFocused()); | |
| press(stateMachine.get(), hostEmpty); | |
| CHECK_FALSE(first->isFocused()); | |
| press(stateMachine.get(), firstField); | |
| CHECK(first->isFocused()); | |
| press(stateMachine.get(), secondField); | |
| CHECK_FALSE(first->isFocused()); | |
| CHECK(second->isFocused()); | |
| } | |
| #endif |