feat(editor - text input): expose trigger property to focus on text i… (#12975) 50fdb3bb9f * feat(editor - text input): expose trigger property to focus on text input * add text keyboard shortcuts * add text selection from pointers Co-authored-by: hernan <hernan@rive.app>
diff --git a/.rive_head b/.rive_head index eb31549..326451e 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -d6079802297ddfbac2ca28c2d467d5e2686010f5 +50fdb3bb9fe4efb95a7cf94f005d4ea1ad86e5c7
diff --git a/include/rive/animation/text_input_listener_group.hpp b/include/rive/animation/text_input_listener_group.hpp index 4a15c63..a61279a 100644 --- a/include/rive/animation/text_input_listener_group.hpp +++ b/include/rive/animation/text_input_listener_group.hpp
@@ -2,6 +2,7 @@ #define _RIVE_TEXT_INPUT_LISTENER_GROUP_HPP_ #include "rive/listener_group.hpp" +#include "rive/math/vec2d.hpp" namespace rive { @@ -31,8 +32,20 @@ StateMachineInstance* stateMachineInstance) override; private: + // Returns a timestamp in microseconds for multi-click detection. Uses the + // passed-in (deterministic) timeStamp when File::deterministicMode is set, + // otherwise wall-clock time, mirroring ScrollPhysics. + long long nowMicros(float timeStamp) const; + TextInput* m_textInput; bool m_isDragging = false; + + // Multi-click (double/triple-click) detection state. + int m_clickCount = 0; + long long m_lastClickTime = 0; // microseconds + Vec2D m_lastClickPosition = Vec2D(0, 0); + static constexpr long long kMultiClickIntervalUs = 500000; // 0.5s + static constexpr float kMultiClickDistance = 16.0f; // world units }; } // namespace rive
diff --git a/include/rive/text/raw_text_input.hpp b/include/rive/text/raw_text_input.hpp index e4c3c7f..a5db1c3 100644 --- a/include/rive/text/raw_text_input.hpp +++ b/include/rive/text/raw_text_input.hpp
@@ -111,6 +111,12 @@ // Selects the word at the cursor. void selectWord(); + // Selects the entire text. + void selectAll(); + + // Selects the visual line at the cursor. + void selectLine(); + std::string text() const; void text(std::string value); void textPreserveCursor(std::string value);
diff --git a/include/rive/text/text_input.hpp b/include/rive/text/text_input.hpp index 73b9d32..c32705b 100644 --- a/include/rive/text/text_input.hpp +++ b/include/rive/text/text_input.hpp
@@ -71,6 +71,12 @@ /// Called when the user ends dragging on the text input. void endDrag(Vec2D worldPosition); + /// Selects the word at the current cursor (e.g. on double-click). + void selectWord(); + + /// Selects the visual line at the current cursor (e.g. on triple-click). + void selectLine(); + /// Advance edge scrolling during drag. Returns true if still scrolling. bool advanceDrag(float elapsedSeconds); bool advanceComponent(float elapsedSeconds,
diff --git a/src/animation/text_input_listener_group.cpp b/src/animation/text_input_listener_group.cpp index d9daf51..d0581d7 100644 --- a/src/animation/text_input_listener_group.cpp +++ b/src/animation/text_input_listener_group.cpp
@@ -1,12 +1,25 @@ #include "rive/animation/text_input_listener_group.hpp" #include "rive/animation/state_machine_instance.hpp" +#include "rive/file.hpp" #include "rive/focus_data.hpp" #include "rive/input/focus_manager.hpp" #include "rive/input/focus_node.hpp" #include "rive/text/text_input.hpp" +#include <chrono> using namespace rive; +long long TextInputListenerGroup::nowMicros(float timeStamp) const +{ + if (File::deterministicMode) + { + return (long long)(timeStamp * 1000000.0f); + } + return std::chrono::duration_cast<std::chrono::microseconds>( + std::chrono::high_resolution_clock::now().time_since_epoch()) + .count(); +} + TextInputListenerGroup::TextInputListenerGroup( TextInput* textInput, StateMachineInstance* stateMachineInstance) : @@ -64,6 +77,23 @@ if (prevPhase != GestureClickPhase::down && newPhase == GestureClickPhase::down) { + // Detect double/triple-click from the time and distance since the + // previous click. + long long now = nowMicros(timeStamp); + long long dt = now - m_lastClickTime; + float dist = Vec2D::distance(position, m_lastClickPosition); + if (dt >= 0 && dt <= kMultiClickIntervalUs && + dist <= kMultiClickDistance) + { + m_clickCount = m_clickCount >= 3 ? 1 : m_clickCount + 1; + } + else + { + m_clickCount = 1; + } + m_lastClickTime = now; + m_lastClickPosition = position; + m_textInput->startDrag(position); m_isDragging = true; @@ -83,6 +113,17 @@ } } } + + // Select the word (double-click) or visual line (triple-click) at the + // cursor that startDrag just placed. + if (m_clickCount == 2) + { + m_textInput->selectWord(); + } + else if (m_clickCount == 3) + { + m_textInput->selectLine(); + } return ProcessEventResult::scroll; } // Handle drag continue: pointer is moving while down
diff --git a/src/text/raw_text_input.cpp b/src/text/raw_text_input.cpp index d933c66..79a96af 100644 --- a/src/text/raw_text_input.cpp +++ b/src/text/raw_text_input.cpp
@@ -470,6 +470,36 @@ flag(Flags::selectionDirty); } +void RawTextInput::selectAll() +{ + ensureShape(); + m_idealCursorX = -1.0f; + CursorPosition start = CursorPosition::atIndex(0, m_shape); + CursorPosition end = CursorPosition::atIndex((uint32_t)length(), m_shape); + m_cursor = Cursor(start, end); + flag(Flags::selectionDirty); +} + +void RawTextInput::selectLine() +{ + ensureShape(); + m_idealCursorX = -1.0f; + CursorPosition cursor = m_cursor.start(); + cursor.resolveLine(m_shape); + const OrderedLine* line = orderedLine(cursor); + if (line == nullptr) + { + return; + } + const auto& glyphLookup = m_shape.glyphLookup(); + CursorPosition start(cursor.lineIndex(), + line->firstCodePointIndex(glyphLookup)); + CursorPosition end(cursor.lineIndex(), + line->lastCodePointIndex(glyphLookup)); + m_cursor = Cursor(start, end); + flag(Flags::selectionDirty); +} + const OrderedLine* RawTextInput::orderedLine(CursorPosition position) const { const std::vector<OrderedLine>& orderedLines = m_shape.orderedLines();
diff --git a/src/text/text_input.cpp b/src/text/text_input.cpp index 97603b7..d7c66bd 100644 --- a/src/text/text_input.cpp +++ b/src/text/text_input.cpp
@@ -395,6 +395,26 @@ return true; } break; + case Key::a: + if ((modifiers & systemModifier()) != KeyModifiers::none) + { + m_rawTextInput.selectAll(); + markPaintDirty(); + return true; + } + break; + case Key::home: + m_rawTextInput.cursorLeft(CursorBoundary::line, + (modifiers & KeyModifiers::shift) != + KeyModifiers::none); + markPaintDirty(); + return true; + case Key::end: + m_rawTextInput.cursorRight(CursorBoundary::line, + (modifiers & KeyModifiers::shift) != + KeyModifiers::none); + markPaintDirty(); + return true; case Key::backspace: m_rawTextInput.backspace(-1); syncSourceTextFromRaw(); @@ -668,6 +688,22 @@ m_scrollY = 0.0f; } +void TextInput::selectWord() +{ +#ifdef WITH_RIVE_TEXT + m_rawTextInput.selectWord(); + markPaintDirty(); +#endif +} + +void TextInput::selectLine() +{ +#ifdef WITH_RIVE_TEXT + m_rawTextInput.selectLine(); + markPaintDirty(); +#endif +} + bool TextInput::advanceDrag(float elapsedSeconds) { #ifdef WITH_RIVE_TEXT
diff --git a/tests/unit_tests/runtime/text_input_test.cpp b/tests/unit_tests/runtime/text_input_test.cpp index 8add568..cd088f7 100644 --- a/tests/unit_tests/runtime/text_input_test.cpp +++ b/tests/unit_tests/runtime/text_input_test.cpp
@@ -254,6 +254,188 @@ 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); +} + +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->stateMachine(0); + if (stateMachine == nullptr) + { + return; + } + + auto abi = artboard->instance(); + StateMachineInstance smi(stateMachine, abi.get()); + smi.advanceAndApply(0.0f); + + auto textInput = abi->objects<TextInput>().first(); + if (textInput == nullptr) + { + return; + } + + textInput->rawTextInput()->text("hello world"); + smi.advanceAndApply(0.0f); + + // Click near the top-left where the first word renders. + Vec2D clickPosition(8.0f, 8.0f); + + auto pressRelease = [&]() { + smi.pointerDown(clickPosition); + smi.pointerUp(clickPosition); + }; + + // Two rapid clicks should select the word under the pointer. + pressRelease(); // single + pressRelease(); // double + smi.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 + smi.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");