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");