From 69448772cce29a86ddac87bfc8b0f990fd5f9448 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 18 Jul 2024 13:28:01 +0100 Subject: [PATCH 1/7] Updates to the Input widget cursor --- src/textual/widgets/_input.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 8e7ba91381..6a072d438d 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -394,6 +394,9 @@ def _watch_cursor_blink(self, blink: bool) -> None: self._cursor_visible = True self._blink_timer.pause() + def watch__cursor_visible(self, cursor_visible: bool) -> None: + self.refresh() + @property def cursor_screen_offset(self) -> Offset: """The offset of the cursor of this input in screen-space. (x, y)/(column, row)""" @@ -505,6 +508,7 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int def _toggle_cursor(self) -> None: """Toggle visibility of cursor.""" self._cursor_visible = not self._cursor_visible + self.refresh() def _on_mount(self, _: Mount) -> None: self._blink_timer = self.set_interval( @@ -526,8 +530,8 @@ def _on_focus(self, _: Focus) -> None: self._suggestion = "" async def _on_key(self, event: events.Key) -> None: - self._cursor_visible = True if self.cursor_blink: + self._cursor_visible = True self._blink_timer.reset() if event.is_printable: @@ -543,6 +547,7 @@ def _on_paste(self, event: events.Paste) -> None: event.stop() async def _on_click(self, event: events.Click) -> None: + print("click!") offset = event.get_content_offset(self) if offset is None: return @@ -559,6 +564,17 @@ async def _on_click(self, event: events.Click) -> None: else: self.cursor_position = len(self.value) + async def _on_mouse_down(self, event: events.MouseDown) -> None: + print("mouse down!") + self._cursor_visible = True + if self.cursor_blink and self._blink_timer: + self._blink_timer.pause() + + async def _on_mouse_up(self, event: events.MouseUp) -> None: + print("mouse up!") + if self.cursor_blink and self._blink_timer: + self._blink_timer.reset() + async def _on_suggestion_ready(self, event: SuggestionReady) -> None: """Handle suggestion messages and set the suggestion when relevant.""" if event.value == self.value: From 03d3616d845044bb69b336fcda4666ae69b5a0af Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 18 Jul 2024 13:30:00 +0100 Subject: [PATCH 2/7] Remove redundant code --- src/textual/widgets/_input.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 6a072d438d..fa6408dd21 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -394,9 +394,6 @@ def _watch_cursor_blink(self, blink: bool) -> None: self._cursor_visible = True self._blink_timer.pause() - def watch__cursor_visible(self, cursor_visible: bool) -> None: - self.refresh() - @property def cursor_screen_offset(self) -> Offset: """The offset of the cursor of this input in screen-space. (x, y)/(column, row)""" @@ -508,7 +505,6 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int def _toggle_cursor(self) -> None: """Toggle visibility of cursor.""" self._cursor_visible = not self._cursor_visible - self.refresh() def _on_mount(self, _: Mount) -> None: self._blink_timer = self.set_interval( @@ -547,7 +543,6 @@ def _on_paste(self, event: events.Paste) -> None: event.stop() async def _on_click(self, event: events.Click) -> None: - print("click!") offset = event.get_content_offset(self) if offset is None: return @@ -565,13 +560,11 @@ async def _on_click(self, event: events.Click) -> None: self.cursor_position = len(self.value) async def _on_mouse_down(self, event: events.MouseDown) -> None: - print("mouse down!") self._cursor_visible = True if self.cursor_blink and self._blink_timer: self._blink_timer.pause() async def _on_mouse_up(self, event: events.MouseUp) -> None: - print("mouse up!") if self.cursor_blink and self._blink_timer: self._blink_timer.reset() From 47d897bad1023ec953576ce85cd769d9219d3ef7 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 5 Aug 2024 10:32:23 +0100 Subject: [PATCH 3/7] Fixing Input widget cursor to match TextArea and remove jankiness --- src/textual/widgets/_input.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index fa6408dd21..6f158a865b 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -391,8 +391,7 @@ def _watch_cursor_blink(self, blink: bool) -> None: if blink: self._blink_timer.resume() else: - self._cursor_visible = True - self._blink_timer.pause() + self._pause_blink_cycle() @property def cursor_screen_offset(self) -> Offset: @@ -514,21 +513,17 @@ def _on_mount(self, _: Mount) -> None: ) def _on_blur(self, _: Blur) -> None: - self._blink_timer.pause() + self._pause_blink_cycle() if "blur" in self.validate_on: self.validate(self.value) def _on_focus(self, _: Focus) -> None: - self.cursor_position = len(self.value) - if self.cursor_blink: - self._blink_timer.resume() + self._restart_blink_cycle() self.app.cursor_position = self.cursor_screen_offset self._suggestion = "" async def _on_key(self, event: events.Key) -> None: - if self.cursor_blink: - self._cursor_visible = True - self._blink_timer.reset() + self._restart_blink_cycle() if event.is_printable: event.stop() @@ -560,19 +555,28 @@ async def _on_click(self, event: events.Click) -> None: self.cursor_position = len(self.value) async def _on_mouse_down(self, event: events.MouseDown) -> None: - self._cursor_visible = True - if self.cursor_blink and self._blink_timer: - self._blink_timer.pause() + self._pause_blink_cycle() async def _on_mouse_up(self, event: events.MouseUp) -> None: - if self.cursor_blink and self._blink_timer: - self._blink_timer.reset() + 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. From 451205f2ca3ae3e62d06f2f4de2cab1a3c5d6712 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 5 Aug 2024 11:35:14 +0100 Subject: [PATCH 4/7] Unit test updates --- src/textual/widgets/_input.py | 14 +++++++++++++- tests/input/test_input_key_modification_actions.py | 10 +++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index eb54f678e3..22fb6b7742 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -335,8 +335,11 @@ def __init__( 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 @@ -392,6 +395,7 @@ def _watch_cursor_blink(self, blink: bool) -> None: self._blink_timer.resume() else: self._pause_blink_cycle() + self._cursor_visible = True @property def cursor_screen_offset(self) -> Offset: @@ -411,6 +415,11 @@ def _watch_value(self, value: str) -> None: ) 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) @@ -733,6 +742,7 @@ def action_delete_left(self) -> None: def action_delete_left_word(self) -> None: """Delete leftward of the cursor position to the start of a word.""" + print(self.cursor_position) if self.cursor_position <= 0: return if self.password: @@ -749,7 +759,9 @@ def action_delete_left_word(self) -> None: 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}" + print(new_value) + self.value = new_value def action_delete_left_all(self) -> None: """Delete all characters to the left of the cursor position.""" diff --git a/tests/input/test_input_key_modification_actions.py b/tests/input/test_input_key_modification_actions.py index bfd935fd9d..187fe5e8b1 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 == "" From 3cab5d401da9a9d6fe41b485af9e40d2e498ff7a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 5 Aug 2024 11:37:47 +0100 Subject: [PATCH 5/7] Fixing more Input tests --- tests/input/test_input_key_movement_actions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/input/test_input_key_movement_actions.py b/tests/input/test_input_key_movement_actions.py index a6cf136947..b47befc6eb 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() From e3b334adb85fa25987796835a9939d54435422a4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 5 Aug 2024 12:35:01 +0100 Subject: [PATCH 6/7] Update CHANGELOG --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea1c614dd6..c96a8420a5 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 From 825594436d51cace7a0c9f52cefab6131516a634 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 5 Aug 2024 12:50:53 +0100 Subject: [PATCH 7/7] Remove debugging prints --- src/textual/widgets/_input.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 22fb6b7742..77ec211584 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -742,7 +742,6 @@ def action_delete_left(self) -> None: def action_delete_left_word(self) -> None: """Delete leftward of the cursor position to the start of a word.""" - print(self.cursor_position) if self.cursor_position <= 0: return if self.password: @@ -760,7 +759,6 @@ def action_delete_left_word(self) -> None: else: self.cursor_position = hit.start() new_value = f"{self.value[: self.cursor_position]}{after}" - print(new_value) self.value = new_value def action_delete_left_all(self) -> None: