Repository: Textualize/textual

Issue:
Focusing Input widget should not move cursor to the end
When the Input widget is focused, the cursor currently moves to the end.

This leads to a flickering effect when it's focused via a click, as the cursor jumps to the end (due to the focus handler), and then jumps to the click location (due to the click handler).

**Desired behaviour:** When the Input is focused via keyboard, the cursor moves back to the position it was previously at the last time it was focused. When focused via click, it goes directly to the click location without jumping to the end first.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea1c614dd..c96a8420a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## Unreleased
+
+### Changed
+
+- Input cursor will no longer jump to the end on focus https://github.com/Textualize/textual/pull/4773
+
+### Fixed
+
+- Input cursor blink effect will now restart correctly when any action is performed on the input https://github.com/Textualize/textual/pull/4773
 
 ## [0.75.1] - 2024-08-02
 
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
index 2e31f70e2..77ec21158 100644
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -23,7 +23,7 @@ from ..css._error_tools import friendly_list
 from ..events import Blur, Focus, Mount
 from ..geometry import Offset, Size
 from ..message import Message
-from ..reactive import reactive, var
+from ..reactive import Reactive, reactive, var
 from ..suggester import Suggester, SuggestionReady
 from ..timer import Timer
 from ..validation import ValidationResult, Validator
@@ -174,7 +174,7 @@ class Input(Widget, can_focus=True):
     cursor_blink = reactive(True, init=False)
     value = reactive("", layout=True, init=False)
     input_scroll_offset = reactive(0)
-    cursor_position = reactive(0)
+    cursor_position: Reactive[int] = reactive(0)
     view_position = reactive(0)
     placeholder = reactive("")
     complete = reactive("")
@@ -335,8 +335,11 @@ class Input(Widget, can_focus=True):
             elif self.type == "number":
                 self.validators.append(Number())
 
+        self._initial_value = True
+        """Indicates if the value has been set for the first time yet."""
         if value is not None:
             self.value = value
+
         if tooltip is not None:
             self.tooltip = tooltip
 
@@ -391,8 +394,8 @@ class Input(Widget, can_focus=True):
             if blink:
                 self._blink_timer.resume()
             else:
+                self._pause_blink_cycle()
                 self._cursor_visible = True
-                self._blink_timer.pause()
 
     @property
     def cursor_screen_offset(self) -> Offset:
@@ -412,6 +415,11 @@ class Input(Widget, can_focus=True):
         )
         self.post_message(self.Changed(self, value, validation_result))
 
+        # If this is the first time the value has been updated, set the cursor position to the end
+        if self._initial_value:
+            self.cursor_position = len(self.value)
+            self._initial_value = False
+
     def _watch_valid_empty(self) -> None:
         """Repeat validation when valid_empty changes."""
         self._watch_value(self.value)
@@ -506,32 +514,25 @@ class Input(Widget, can_focus=True):
         """Toggle visibility of cursor."""
         self._cursor_visible = not self._cursor_visible
 
-    def _on_mount(self, _: Mount) -> None:
+    def _on_mount(self, event: Mount) -> None:
         self._blink_timer = self.set_interval(
             0.5,
             self._toggle_cursor,
             pause=not (self.cursor_blink and self.has_focus),
         )
 
-    def _on_blur(self, _: Blur) -> None:
-        assert self._blink_timer is not None
-        self._blink_timer.pause()
+    def _on_blur(self, event: Blur) -> None:
+        self._pause_blink_cycle()
         if "blur" in self.validate_on:
             self.validate(self.value)
 
-    def _on_focus(self, _: Focus) -> None:
-        assert self._blink_timer is not None
-        self.cursor_position = len(self.value)
-        if self.cursor_blink:
-            self._blink_timer.resume()
+    def _on_focus(self, event: Focus) -> None:
+        self._restart_blink_cycle()
         self.app.cursor_position = self.cursor_screen_offset
         self._suggestion = ""
 
     async def _on_key(self, event: events.Key) -> None:
-        assert self._blink_timer is not None
-        self._cursor_visible = True
-        if self.cursor_blink:
-            self._blink_timer.reset()
+        self._restart_blink_cycle()
 
         if event.is_printable:
             event.stop()
@@ -562,11 +563,29 @@ class Input(Widget, can_focus=True):
         else:
             self.cursor_position = len(self.value)
 
+    async def _on_mouse_down(self, event: events.MouseDown) -> None:
+        self._pause_blink_cycle()
+
+    async def _on_mouse_up(self, event: events.MouseUp) -> None:
+        self._restart_blink_cycle()
+
     async def _on_suggestion_ready(self, event: SuggestionReady) -> None:
         """Handle suggestion messages and set the suggestion when relevant."""
         if event.value == self.value:
             self._suggestion = event.suggestion
 
+    def _restart_blink_cycle(self) -> None:
+        """Restart the cursor blink cycle."""
+        self._cursor_visible = True
+        if self.cursor_blink and self._blink_timer:
+            self._blink_timer.reset()
+
+    def _pause_blink_cycle(self) -> None:
+        """Hide the blinking cursor and pause the blink cycle."""
+        self._cursor_visible = False
+        if self.cursor_blink and self._blink_timer:
+            self._blink_timer.pause()
+
     def insert_text_at_cursor(self, text: str) -> None:
         """Insert new text at the cursor, move the cursor to the end of the new text.
 
@@ -739,7 +758,8 @@ class Input(Widget, can_focus=True):
                 self.cursor_position = 0
             else:
                 self.cursor_position = hit.start()
-            self.value = f"{self.value[: self.cursor_position]}{after}"
+            new_value = f"{self.value[: self.cursor_position]}{after}"
+            self.value = new_value
 
     def action_delete_left_all(self) -> None:
         """Delete all characters to the left of the cursor position."""


Test patch:
diff --git a/tests/input/test_input_key_modification_actions.py b/tests/input/test_input_key_modification_actions.py
index bfd935fd9..187fe5e8b 100644
--- a/tests/input/test_input_key_modification_actions.py
+++ b/tests/input/test_input_key_modification_actions.py
@@ -25,6 +25,7 @@ async def test_delete_left_from_home() -> None:
     """Deleting left from home should do nothing."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_delete_left()
             assert input.cursor_position == 0
             assert input.value == TEST_INPUTS[input.id]
@@ -44,6 +45,7 @@ async def test_delete_left_word_from_home() -> None:
     """Deleting word left from home should do nothing."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_delete_left_word()
             assert input.cursor_position == 0
             assert input.value == TEST_INPUTS[input.id]
@@ -68,7 +70,6 @@ async def test_delete_left_word_from_end() -> None:
             "multi-and-hyphenated": "Long as she does it quiet-",
         }
         for input in pilot.app.query(Input):
-            input.action_end()
             input.action_delete_left_word()
             assert input.cursor_position == len(input.value)
             assert input.value == expected[input.id]
@@ -78,7 +79,6 @@ async def test_password_delete_left_word_from_end() -> None:
     """Deleting word left from end of a password input should delete everything."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
-            input.action_end()
             input.password = True
             input.action_delete_left_word()
             assert input.cursor_position == 0
@@ -89,6 +89,7 @@ async def test_delete_left_all_from_home() -> None:
     """Deleting all left from home should do nothing."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_delete_left_all()
             assert input.cursor_position == 0
             assert input.value == TEST_INPUTS[input.id]
@@ -108,6 +109,7 @@ async def test_delete_right_from_home() -> None:
     """Deleting right from home should delete one character (if there is any to delete)."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_delete_right()
             assert input.cursor_position == 0
             assert input.value == TEST_INPUTS[input.id][1:]
@@ -117,7 +119,6 @@ async def test_delete_right_from_end() -> None:
     """Deleting right from end should not change the input's value."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
-            input.action_end()
             input.action_delete_right()
             assert input.cursor_position == len(input.value)
             assert input.value == TEST_INPUTS[input.id]
@@ -133,6 +134,7 @@ async def test_delete_right_word_from_home() -> None:
             "multi-and-hyphenated": "as she does it quiet-like",
         }
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_delete_right_word()
             assert input.cursor_position == 0
             assert input.value == expected[input.id]
@@ -142,6 +144,7 @@ async def test_password_delete_right_word_from_home() -> None:
     """Deleting word right from home of a password input should delete everything."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.password = True
             input.action_delete_right_word()
             assert input.cursor_position == 0
@@ -162,6 +165,7 @@ async def test_delete_right_all_from_home() -> None:
     """Deleting all right home should remove everything in the input."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_delete_right_all()
             assert input.cursor_position == 0
             assert input.value == ""
diff --git a/tests/input/test_input_key_movement_actions.py b/tests/input/test_input_key_movement_actions.py
index a6cf13694..b47befc6e 100644
--- a/tests/input/test_input_key_movement_actions.py
+++ b/tests/input/test_input_key_movement_actions.py
@@ -43,6 +43,7 @@ async def test_input_right_from_home() -> None:
     """Going right should always land at the next position, if there is one."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_cursor_right()
             assert input.cursor_position == (1 if input.value else 0)
 
@@ -60,6 +61,7 @@ async def test_input_left_from_home() -> None:
     """Going left from home should stay put."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_cursor_left()
             assert input.cursor_position == 0
 
@@ -77,6 +79,7 @@ async def test_input_left_word_from_home() -> None:
     """Going left one word from the start should do nothing."""
     async with InputTester().run_test() as pilot:
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_cursor_left_word()
             assert input.cursor_position == 0
 
@@ -118,6 +121,7 @@ async def test_input_right_word_from_home() -> None:
             "multi-and-hyphenated": 5,
         }
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             input.action_cursor_right_word()
             assert input.cursor_position == expected_at[input.id]
 
@@ -151,6 +155,7 @@ async def test_input_right_word_to_the_end() -> None:
             "multi-and-hyphenated": 7,
         }
         for input in pilot.app.query(Input):
+            input.cursor_position = 0
             hops = 0
             while input.cursor_position < len(input.value):
                 input.action_cursor_right_word()