fix(text input): put the caret after pre-filled text on focus without a press (#15258)

* fix(text input): put the caret after pre-filled text on focus without a press

Text set from outside (the file's own value, a data bind) leaves the caret
at the start, and focus only moved it for Tab or selectAllOnFocus. A field
focused on a state's entry, from code or by accessibility showed the caret
before its text, so typing went in front of it.

Focus now puts the caret after the text unless the user placed it since the
text was last set from outside: a press places it before focusing, and keys,
typing and earlier focus keep it, so refocusing finds it where the user left
it. Tab and selectAllOnFocus still select all.

Adds RawTextInput::moveCursorToEnd, exported to Dart (rawTextMoveCursorToEnd)
so the editor's TextInput behaves the same.

* fix(text input): place the caret at the end before the font arrives

A focus on a state's entry can land before the field's font is available,
and moveCursorToEnd shaped the text to resolve the caret's line, which
dereferences the missing font. Without a font, store the end position with
its line unresolved and let update() resolve it once the font arrives.

* fix(text input): select all before the font arrives

Tab or selectAllOnFocus can focus a field before its font is available, and
selectAll shaped the text to resolve the selection's lines, dereferencing
the missing font. selectAll and moveCursorToEnd now share positionAtIndex,
which leaves the line for update() to resolve when there is no font yet.

Also puts a test case header on one line, as clang-format 19 wants it.

* test(capture): let the user place the caret the claimed press must keep

The nested capture test set the caret through the raw input, which no longer
counts as placed, so the claim's focus action moved it after the text. Place
it with a key while focused instead: focus coming back keeps it, so the
caret still shows the input's press handling never ran.

Rive-Mono-Commit: 8199ff51213dda03caf4427c394ddcce0856db41
diff --git a/include/rive/text/raw_text_input.hpp b/include/rive/text/raw_text_input.hpp
index f119983..2013e75 100644
--- a/include/rive/text/raw_text_input.hpp
+++ b/include/rive/text/raw_text_input.hpp
@@ -170,6 +170,9 @@
   // Selects the entire text.
   void selectAll();
 
+  // Collapses the cursor after the last character.
+  void moveCursorToEnd();
+
   // Selects the visual line at the cursor.
   void selectLine();
 
@@ -234,6 +237,10 @@
       size_t count,
       bool withRestOfParagraph);
   void ensureShape();
+  // The position at a code point index. Its line is resolved now when the
+  // text can be shaped, else left for update() to resolve once the font
+  // arrives: a focus can select or place the caret before then.
+  CursorPosition positionAtIndex(uint32_t codePointIndex);
   std::vector<Unichar>& shapeableText();
   void buildTextPaths(Factory* factory);
   void computeVisualPositionFromCursor();
diff --git a/include/rive/text/text_input.hpp b/include/rive/text/text_input.hpp
index 8bf787c..4ccd584 100644
--- a/include/rive/text/text_input.hpp
+++ b/include/rive/text/text_input.hpp
@@ -165,6 +165,10 @@
   /// Whether the user is currently dragging to select text.
   bool m_isDragging = false;
 
+  /// Whether the caret was placed (by a press, a key, typing or a focus)
+  /// since text set from outside last reset it to the start.
+  bool m_caretPlaced = false;
+
   /// Whether this text input currently has focus.
   bool m_focused = false;
 
diff --git a/src/text/raw_text_input.cpp b/src/text/raw_text_input.cpp
index dc1f404..5462352 100644
--- a/src/text/raw_text_input.cpp
+++ b/src/text/raw_text_input.cpp
@@ -710,13 +710,28 @@
   flag(Flags::selectionDirty);
 }
 
+CursorPosition RawTextInput::positionAtIndex(uint32_t codePointIndex)
+{
+  // There is no shaping without a font.
+  if (m_textRun.font == nullptr)
+  {
+    return CursorPosition(codePointIndex);
+  }
+  ensureShape();
+  return CursorPosition::atIndex(codePointIndex, m_shape);
+}
+
 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);
+  m_cursor = Cursor(positionAtIndex(0), positionAtIndex((uint32_t)length()));
+  flag(Flags::selectionDirty);
+}
+
+void RawTextInput::moveCursorToEnd()
+{
+  m_idealCursorX = -1.0f;
+  m_cursor = Cursor::collapsed(positionAtIndex((uint32_t)length()));
   flag(Flags::selectionDirty);
 }
 
diff --git a/src/text/text_input.cpp b/src/text/text_input.cpp
index 2ad5a60..f3d946c 100644
--- a/src/text/text_input.cpp
+++ b/src/text/text_input.cpp
@@ -490,6 +490,7 @@
   else
   {
     m_rawTextInput.text(nextDisplayText);
+    m_caretPlaced = false;
   }
 #endif
 }
@@ -598,6 +599,7 @@
 #ifdef WITH_RIVE_TEXT
   if (isPressed)
   {
+    m_caretPlaced = true;
     switch (value)
     {
       case Key::z:
@@ -694,6 +696,7 @@
 bool TextInput::textInput(const std::string& value)
 {
 #ifdef WITH_RIVE_TEXT
+  m_caretPlaced = true;
   std::string textToInsert = multiline() ? value : strippedLineBreaks(value);
   if (textToInsert.empty())
   {
@@ -737,12 +740,19 @@
 {
   m_focused = true;
 #ifdef WITH_RIVE_TEXT
-  // Keyboard focus selects the text browser style, pointer focus keeps the
-  // caret the press placed.
+  // Keyboard focus selects the text browser style. Any other focus keeps the
+  // caret the user placed (a press places it before focusing), but text set
+  // from outside leaves it at the start, so it goes after the text, ready to
+  // type.
   if (selectAllOnFocus() || focusedByTraversal())
   {
     m_rawTextInput.selectAll();
   }
+  else if (!m_caretPlaced)
+  {
+    m_rawTextInput.moveCursorToEnd();
+  }
+  m_caretPlaced = true;
 #endif
   markPaintDirty();
 }
@@ -931,6 +941,7 @@
 {
 #ifdef WITH_RIVE_TEXT
   m_isDragging = true;
+  m_caretPlaced = true;
   m_lastDragWorldPosition = worldPosition;
 
   Vec2D localPos;
diff --git a/tests/unit_tests/runtime/capture_listener_test.cpp b/tests/unit_tests/runtime/capture_listener_test.cpp
index 6a285db..3b8a489 100644
--- a/tests/unit_tests/runtime/capture_listener_test.cpp
+++ b/tests/unit_tests/runtime/capture_listener_test.cpp
@@ -426,7 +426,11 @@
   auto file = ReadRiveFile("assets/capture_listeners.riv");
   capture(file.get(), "Field", "Viewport Down");
   HostScene scene(file.get());
-  scene.b.caret(0);
+  // The user left the caret at the start, where focus coming back keeps it.
+  scene.focus(scene.b);
+  CHECK(scene.press(Key::up));
+  REQUIRE(scene.b.caret() == 0);
+  scene.machine->clearFocus();
 
   // The field's Viewport claims the press on its text: its own action
   // focuses the input, and the input's built-in press handling never runs,
diff --git a/tests/unit_tests/runtime/raw_text_input_test.cpp b/tests/unit_tests/runtime/raw_text_input_test.cpp
index ee3de76..0ddf399 100644
--- a/tests/unit_tests/runtime/raw_text_input_test.cpp
+++ b/tests/unit_tests/runtime/raw_text_input_test.cpp
@@ -104,6 +104,43 @@
   CHECK(position.bottom() == Approx(87.11719f));
 }
 
+TEST_CASE("moveCursorToEnd before the font arrives", "[text_input]")
+{
+  // A focus can land before the font does: the caret still goes after the
+  // text, and its line resolves once the text can be shaped.
+  RawTextInput textInput;
+  textInput.text("hello");
+  textInput.moveCursorToEnd();
+  CHECK(textInput.cursor().isCollapsed());
+  CHECK(textInput.cursor().end().codePointIndex() == 5);
+  CHECK_FALSE(textInput.cursor().end().hasLineIndex());
+
+  textInput.font(loadFont("assets/fonts/IBMPlexSansArabic-Regular.ttf"));
+  NoOpFactory factory;
+  textInput.update(&factory);
+  CHECK(textInput.cursor().end().codePointIndex() == 5);
+  CHECK(textInput.cursor().end().lineIndex() == 0);
+}
+
+TEST_CASE("selectAll before the font arrives", "[text_input]")
+{
+  // Tab or selectAllOnFocus can focus before the font arrives: the text is
+  // still selected, and the lines resolve once it can be shaped.
+  RawTextInput textInput;
+  textInput.text("hello");
+  textInput.selectAll();
+  CHECK(textInput.selectedText() == "hello");
+  CHECK_FALSE(textInput.cursor().start().hasLineIndex());
+  CHECK_FALSE(textInput.cursor().end().hasLineIndex());
+
+  textInput.font(loadFont("assets/fonts/IBMPlexSansArabic-Regular.ttf"));
+  NoOpFactory factory;
+  textInput.update(&factory);
+  CHECK(textInput.selectedText() == "hello");
+  CHECK(textInput.cursor().start().lineIndex() == 0);
+  CHECK(textInput.cursor().end().lineIndex() == 0);
+}
+
 TEST_CASE("cursor is placed correctly with ltr paragraphs", "[text_input]")
 {
   auto font = loadFont("assets/fonts/IBMPlexSansArabic-Regular.ttf");
diff --git a/tests/unit_tests/runtime/text_input_test.cpp b/tests/unit_tests/runtime/text_input_test.cpp
index 10c245a..5778dcd 100644
--- a/tests/unit_tests/runtime/text_input_test.cpp
+++ b/tests/unit_tests/runtime/text_input_test.cpp
@@ -1258,7 +1258,7 @@
   auto focusData = artboard->objects<FocusData>().first();
   REQUIRE(focusData != nullptr);
 
-  // Target focus keeps the caret where it was.
+  // Target focus places a caret, not a selection.
   stateMachine->setFocus(focusData);
   CHECK(textInput->isFocused());
   CHECK(textInput->rawTextInput()->cursor().isCollapsed());
@@ -1269,6 +1269,61 @@
   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");