Repository: Textualize/textual

Issue:
Input - clicking the right half of a double width character moves the cursor to the wrong character.
こんにちは

Put that text into an `Input` and click on the right half of any character - notice that the cursor incorrectly moves to the character to the right of the one you clicked on.



Discussion:
I isolated the source of the bug here:
https://github.com/Textualize/textual/blob/5a662e68aefc695c02203c8b3720a4c90a7dce09/src/textual/widgets/_input.py#L424

Will upload a solution soon.

Resolving patch:
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -421,10 +421,11 @@ async def _on_click(self, event: events.Click) -> None:
         cell_offset = 0
         _cell_size = get_character_cell_size
         for index, char in enumerate(self.value):
-            if cell_offset >= click_x:
+            cell_width = _cell_size(char)
+            if cell_offset <= click_x < (cell_offset + cell_width):
                 self.cursor_position = index
                 break
-            cell_offset += _cell_size(char)
+            cell_offset += cell_width
         else:
             self.cursor_position = len(self.value)
 


Test patch:
diff --git a/tests/input/test_input_mouse.py b/tests/input/test_input_mouse.py
--- a/tests/input/test_input_mouse.py
+++ b/tests/input/test_input_mouse.py
@@ -6,28 +6,61 @@
 from textual.geometry import Offset
 from textual.widgets import Input
 
+# A string containing only single-width characters
+TEXT_SINGLE = "That gum you like is going to come back in style"
+
+# A string containing only double-width characters
+TEXT_DOUBLE = "こんにちは"
+
+# A string containing both single and double-width characters
+TEXT_MIXED = "aこんbcにdちeは"
+
 
 class InputApp(App[None]):
-    TEST_TEXT = "That gum you like is going to come back in style"
+    def __init__(self, text):
+        super().__init__()
+        self._text = text
 
     def compose(self) -> ComposeResult:
-        yield Input(self.TEST_TEXT)
+        yield Input(self._text)
 
 
 @pytest.mark.parametrize(
-    "click_at, should_land",
+    "text, click_at, should_land",
     (
-        (0, 0),
-        (1, 1),
-        (10, 10),
-        (len(InputApp.TEST_TEXT) - 1, len(InputApp.TEST_TEXT) - 1),
-        (len(InputApp.TEST_TEXT), len(InputApp.TEST_TEXT)),
-        (len(InputApp.TEST_TEXT) * 2, len(InputApp.TEST_TEXT)),
+        # Single-width characters
+        (TEXT_SINGLE, 0, 0),
+        (TEXT_SINGLE, 1, 1),
+        (TEXT_SINGLE, 10, 10),
+        (TEXT_SINGLE, len(TEXT_SINGLE) - 1, len(TEXT_SINGLE) - 1),
+        (TEXT_SINGLE, len(TEXT_SINGLE), len(TEXT_SINGLE)),
+        (TEXT_SINGLE, len(TEXT_SINGLE) * 2, len(TEXT_SINGLE)),
+        # Double-width characters
+        (TEXT_DOUBLE, 0, 0),
+        (TEXT_DOUBLE, 1, 0),
+        (TEXT_DOUBLE, 2, 1),
+        (TEXT_DOUBLE, 3, 1),
+        (TEXT_DOUBLE, 4, 2),
+        (TEXT_DOUBLE, 5, 2),
+        (TEXT_DOUBLE, (len(TEXT_DOUBLE) * 2) - 1, len(TEXT_DOUBLE) - 1),
+        (TEXT_DOUBLE, len(TEXT_DOUBLE) * 2, len(TEXT_DOUBLE)),
+        (TEXT_DOUBLE, len(TEXT_DOUBLE) * 10, len(TEXT_DOUBLE)),
+        # Mixed-width characters
+        (TEXT_MIXED, 0, 0),
+        (TEXT_MIXED, 1, 1),
+        (TEXT_MIXED, 2, 1),
+        (TEXT_MIXED, 3, 2),
+        (TEXT_MIXED, 4, 2),
+        (TEXT_MIXED, 5, 3),
+        (TEXT_MIXED, 13, 9),
+        (TEXT_MIXED, 14, 9),
+        (TEXT_MIXED, 15, 10),
+        (TEXT_MIXED, 1000, 10),
     ),
 )
-async def test_mouse_clicks_within(click_at, should_land):
+async def test_mouse_clicks_within(text, click_at, should_land):
     """Mouse clicks should result in the cursor going to the right place."""
-    async with InputApp().run_test() as pilot:
+    async with InputApp(text).run_test() as pilot:
         # Note the offsets to take into account the decoration around an
         # Input.
         await pilot.click(Input, Offset(click_at + 3, 1))
@@ -37,7 +70,7 @@ async def test_mouse_clicks_within(click_at, should_land):
 
 async def test_mouse_click_outwith():
     """Mouse clicks outside the input should not affect cursor position."""
-    async with InputApp().run_test() as pilot:
+    async with InputApp(TEXT_SINGLE).run_test() as pilot:
         pilot.app.query_one(Input).cursor_position = 3
         assert pilot.app.query_one(Input).cursor_position == 3
         await pilot.click(Input, Offset(0, 0))