From 12cfec7be3a8100f22088464c42136cd4317112f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 30 Jan 2023 16:51:45 +0000 Subject: [PATCH 1/4] When in password mode have word-oriented actions act on whole input The idea here is that a password field should give no hint as to what's within, length notwithstanding. To this end have the actions that (re)move based on word boundaries act as if a password field is always just one word. See #1692 (and previously #1676, prompted originally by #1310). --- src/textual/widgets/_input.py | 68 ++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index b09b96310..64f0fd942 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -378,20 +378,32 @@ class Input(Widget, can_focus=True): def action_cursor_left_word(self) -> None: """Move the cursor left to the start of a word.""" - try: - *_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position]) - except ValueError: - self.cursor_position = 0 + if self.password: + # This is a password field so don't give any hints about word + # boundaries, even during movement. + self.action_home() else: - self.cursor_position = hit.start() + try: + *_, hit = re.finditer( + self._WORD_START, self.value[: self.cursor_position] + ) + except ValueError: + self.cursor_position = 0 + else: + self.cursor_position = hit.start() def action_cursor_right_word(self) -> None: """Move the cursor right to the start of a word.""" - hit = re.search(self._WORD_START, self.value[self.cursor_position :]) - if hit is None: - self.cursor_position = len(self.value) + if self.password: + # This is a password field so don't give any hints about word + # boundaries, even during movement. + self.action_end() else: - self.cursor_position += hit.start() + hit = re.search(self._WORD_START, self.value[self.cursor_position :]) + if hit is None: + self.cursor_position = len(self.value) + else: + self.cursor_position += hit.start() def action_delete_right(self) -> None: """Delete one character at the current cursor position.""" @@ -404,12 +416,19 @@ class Input(Widget, can_focus=True): def action_delete_right_word(self) -> None: """Delete the current character and all rightward to the start of the next word.""" - after = self.value[self.cursor_position :] - hit = re.search(self._WORD_START, after) - if hit is None: - self.value = self.value[: self.cursor_position] + if self.password: + # This is a password field so don't give any hints about word + # boundaries, even during deletion. + self.action_delete_right_all() else: - self.value = f"{self.value[: self.cursor_position]}{after[hit.end()-1 :]}" + after = self.value[self.cursor_position :] + hit = re.search(self._WORD_START, after) + if hit is None: + self.value = self.value[: self.cursor_position] + else: + self.value = ( + f"{self.value[: self.cursor_position]}{after[hit.end()-1 :]}" + ) def action_delete_right_all(self) -> None: """Delete the current character and all characters to the right of the cursor position.""" @@ -437,14 +456,21 @@ class Input(Widget, can_focus=True): """Delete leftward of the cursor position to the start of a word.""" if self.cursor_position <= 0: return - after = self.value[self.cursor_position :] - try: - *_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position]) - except ValueError: - self.cursor_position = 0 + if self.password: + # This is a password field so don't give any hints about word + # boundaries, even during deletion. + self.action_delete_left_all() else: - self.cursor_position = hit.start() - self.value = f"{self.value[: self.cursor_position]}{after}" + after = self.value[self.cursor_position :] + try: + *_, hit = re.finditer( + self._WORD_START, self.value[: self.cursor_position] + ) + except ValueError: + self.cursor_position = 0 + else: + self.cursor_position = hit.start() + self.value = f"{self.value[: self.cursor_position]}{after}" def action_delete_left_all(self) -> None: """Delete all characters to the left of the cursor position.""" From 5fc16c6af0f7fd6f0fde69a123fd3f3c3d8c4401 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 30 Jan 2023 20:34:22 +0000 Subject: [PATCH 2/4] Add extra unit tests for password field movement Here we're just testing the exceptional situations. --- .../input/test_input_key_movement_actions.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/input/test_input_key_movement_actions.py b/tests/input/test_input_key_movement_actions.py index 2d0bc1338..a6cf13694 100644 --- a/tests/input/test_input_key_movement_actions.py +++ b/tests/input/test_input_key_movement_actions.py @@ -97,6 +97,16 @@ async def test_input_left_word_from_end() -> None: assert input.cursor_position == expected_at[input.id] +async def test_password_input_left_word_from_end() -> None: + """Going left one word from the end in a password field should land at home.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + input.password = True + input.action_cursor_left_word() + assert input.cursor_position == 0 + + async def test_input_right_word_from_home() -> None: """Going right one word from the start should land correctly..""" async with InputTester().run_test() as pilot: @@ -112,6 +122,15 @@ async def test_input_right_word_from_home() -> None: assert input.cursor_position == expected_at[input.id] +async def test_password_input_right_word_from_home() -> None: + """Going right one word from the start of a password input should go to the end.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.password = True + input.action_cursor_right_word() + assert input.cursor_position == len(input.value) + + async def test_input_right_word_from_end() -> None: """Going right one word from the end should do nothing.""" async with InputTester().run_test() as pilot: From 928a289c0e3c55e47368473e4570d2ce7ea7e19b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 30 Jan 2023 20:38:19 +0000 Subject: [PATCH 3/4] Add extra unit tests for password field deletion Here we're just testing the exceptional situations. --- .../test_input_key_modification_actions.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/input/test_input_key_modification_actions.py b/tests/input/test_input_key_modification_actions.py index 51b124d7d..68f7e50c8 100644 --- a/tests/input/test_input_key_modification_actions.py +++ b/tests/input/test_input_key_modification_actions.py @@ -66,6 +66,17 @@ async def test_delete_left_word_from_end() -> None: assert input.value == expected[input.id] +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 + assert input.value == "" + + async def test_delete_left_all_from_home() -> None: """Deleting all left from home should do nothing.""" async with InputTester().run_test() as pilot: @@ -119,6 +130,16 @@ async def test_delete_right_word_from_home() -> None: assert input.value == expected[input.id] +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.password = True + input.action_delete_right_word() + assert input.cursor_position == 0 + assert input.value == "" + + async def test_delete_right_word_from_end() -> None: """Deleting word right from end should not change the input's value.""" async with InputTester().run_test() as pilot: From 8bfe4e817013f459ef15094b9494f82d131c9d08 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 31 Jan 2023 10:36:07 +0000 Subject: [PATCH 4/4] Fix unintentional indent --- src/textual/widgets/_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 64f0fd942..5af98629b 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -470,7 +470,7 @@ 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}" + self.value = f"{self.value[: self.cursor_position]}{after}" def action_delete_left_all(self) -> None: """Delete all characters to the left of the cursor position."""