From f207df003cee96e8759a0bb677bcd9e1e847bcac Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Sat, 25 Jan 2025 13:48:36 +0000 Subject: [PATCH 1/6] fix(masked input): highlight selected text Fixes #5495. --- src/textual/widgets/_masked_input.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index 560258f332..5d57b88d92 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -569,12 +569,19 @@ def render_line(self, y: int) -> Strip: if char == " ": result.stylize(style, index, index + 1) - if self._cursor_visible and self.has_focus: - if self._cursor_at_end: - result.pad_right(1) - cursor_style = self.get_component_rich_style("input--cursor") - cursor = self.cursor_position - result.stylize(cursor_style, cursor, cursor + 1) + if self.has_focus: + if not self.selection.is_empty: + start, end = self.selection + start, end = sorted((start, end)) + selection_style = self.get_component_rich_style("input--selection") + result.stylize_before(selection_style, start, end) + + if self._cursor_visible: + cursor_style = self.get_component_rich_style("input--cursor") + cursor = self.cursor_position + if self._cursor_at_end: + result.pad_right(1) + result.stylize(cursor_style, cursor, cursor + 1) segments = list(result.render(self.app.console)) line_length = Segment.get_line_length(segments) From 9c04be5c8a2f475c104632722ab447da051225eb Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Sat, 25 Jan 2025 14:57:20 +0000 Subject: [PATCH 2/6] docs(masked input): add missing select_on_focus to docstring --- src/textual/widgets/_masked_input.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index 5d57b88d92..58b56689a5 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -473,6 +473,7 @@ def __init__( which determine when to do input validation. The default is to do validation for all messages. valid_empty: Empty values are valid. + select_on_focus: Whether to select all text on focus. name: Optional name for the masked input widget. id: Optional ID for the widget. classes: Optional initial classes for the widget. From f3da9fa56fde9b7a4e2bbc2ebe851fc3b46a9fe9 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Fri, 14 Feb 2025 15:24:11 +0000 Subject: [PATCH 3/6] fix(masked input): fix method override signatures --- src/textual/widgets/_masked_input.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index 58b56689a5..b7139651f3 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -622,19 +622,19 @@ def clear(self) -> None: """Clear the masked input.""" self.value, self.cursor_position = self._template.insert_separators("", 0) - def action_cursor_left(self) -> None: + def action_cursor_left(self, select: bool = False) -> None: """Move the cursor one position to the left; separators are skipped.""" self._template.move_cursor(-1) - def action_cursor_right(self) -> None: + def action_cursor_right(self, select: bool = False) -> None: """Move the cursor one position to the right; separators are skipped.""" self._template.move_cursor(1) - def action_home(self) -> None: + def action_home(self, select: bool = False) -> None: """Move the cursor to the start of the input.""" self._template.move_cursor(-len(self.template)) - def action_cursor_left_word(self) -> None: + def action_cursor_left_word(self, select: bool = False) -> None: """Move the cursor left next to the previous separator. If no previous separator is found, moves the cursor to the start of the input.""" if self._template.at_separator(self.cursor_position - 1): @@ -645,7 +645,7 @@ def action_cursor_left_word(self) -> None: position += 1 self.cursor_position = position or 0 - def action_cursor_right_word(self) -> None: + def action_cursor_right_word(self, select: bool = False) -> None: """Move the cursor right next to the next separator. If no next separator is found, moves the cursor to the end of the input.""" position = self._template.next_separator_position() From 8ad251a174c5d35ba427d41368cb78218492adb8 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Fri, 14 Feb 2025 15:36:15 +0000 Subject: [PATCH 4/6] fix(masked input): fix insert_text_at_cursor typing --- src/textual/widgets/_masked_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index b7139651f3..c8929b56c8 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -200,7 +200,7 @@ def insert_separators(self, value: str, cursor_position: int) -> tuple[str, int] cursor_position += 1 return value, cursor_position - def insert_text_at_cursor(self, text: str) -> str | None: + def insert_text_at_cursor(self, text: str) -> tuple[str, int] | None: """Inserts `text` at current cursor position. If not present in `text`, any expected separator is automatically inserted at the correct position. From c66e80ee9ff7763ac35ebc70d2de0a6ceae97377 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Fri, 14 Feb 2025 18:24:09 +0000 Subject: [PATCH 5/6] fix(masked input): fix selection with shift + arrow keys --- src/textual/widgets/_masked_input.py | 35 +++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index c8929b56c8..c39c643d3f 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -12,6 +12,7 @@ from textual import events from textual.strip import Strip +from textual.widgets.input import Selection if TYPE_CHECKING: pass @@ -256,15 +257,17 @@ def insert_text_at_cursor(self, text: str) -> tuple[str, int] | None: value, cursor_position = self.insert_separators(value, cursor_position) return value, cursor_position - def move_cursor(self, delta: int) -> None: + def move_cursor(self, delta: int, select: bool = False) -> None: """Moves the cursor position by `delta` characters, skipping separators if running over them. Args: delta: The number of characters to move; positive moves right, negative moves left. + select: If `True`, select the text between the old and new cursor positions. """ cursor_position = self.input.cursor_position + start, end = self.input.selection if delta < 0 and all( [ (_CharFlags.SEPARATOR in char_definition.flags) @@ -279,7 +282,11 @@ def move_cursor(self, delta: int) -> None: and (_CharFlags.SEPARATOR in self.template[cursor_position].flags) ): cursor_position += delta - self.input.cursor_position = cursor_position + + if select: + self.input.selection = Selection(start, cursor_position) + else: + self.input.cursor_position = cursor_position def delete_at_position(self, position: int | None = None) -> None: """Deletes character at `position`. @@ -623,16 +630,28 @@ def clear(self) -> None: self.value, self.cursor_position = self._template.insert_separators("", 0) def action_cursor_left(self, select: bool = False) -> None: - """Move the cursor one position to the left; separators are skipped.""" - self._template.move_cursor(-1) + """Move the cursor one position to the left; separators are skipped. + + Args: + select: If `True`, select the text to the left of the cursor. + """ + self._template.move_cursor(-1, select=select) def action_cursor_right(self, select: bool = False) -> None: - """Move the cursor one position to the right; separators are skipped.""" - self._template.move_cursor(1) + """Move the cursor one position to the right; separators are skipped. + + Args: + select: If `True`, select the text to the right of the cursor. + """ + self._template.move_cursor(1, select=select) def action_home(self, select: bool = False) -> None: - """Move the cursor to the start of the input.""" - self._template.move_cursor(-len(self.template)) + """Move the cursor to the start of the input. + + Args: + select: If `True`, select the text between the old and new cursor positions. + """ + self._template.move_cursor(-len(self.template), select=select) def action_cursor_left_word(self, select: bool = False) -> None: """Move the cursor left next to the previous separator. If no previous From de7201e73aef5c22bb2b39bc7a29607ccfb3e1c4 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Tue, 25 Feb 2025 18:06:20 +0000 Subject: [PATCH 6/6] fix(masked input): fix selecting left & right 'words' Fix selecting left & right 'words' in the `MaskedInput`, where 'words' are defined by the template separators. These key bindings are inherited from the updated `Input` widget, but the overridden methods in `MaskedInput` were not updated to reflect these changes. --- src/textual/widgets/_masked_input.py | 45 +++++++++++++++++++++------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index c39c643d3f..b8cdb6612f 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -655,23 +655,46 @@ def action_home(self, select: bool = False) -> None: def action_cursor_left_word(self, select: bool = False) -> None: """Move the cursor left next to the previous separator. If no previous - separator is found, moves the cursor to the start of the input.""" + separator is found, moves the cursor to the start of the input. + + Args: + select: If `True`, select the text between the old and new cursor positions. + """ if self._template.at_separator(self.cursor_position - 1): - position = self._template.prev_separator_position(self.cursor_position - 1) + separator_position = self._template.prev_separator_position( + self.cursor_position - 1 + ) else: - position = self._template.prev_separator_position() - if position: - position += 1 - self.cursor_position = position or 0 + separator_position = self._template.prev_separator_position() + if separator_position: + target = separator_position + 1 + else: + target = 0 + + if select: + start, _ = self.selection + self.selection = Selection(start, target) + else: + self.cursor_position = target def action_cursor_right_word(self, select: bool = False) -> None: """Move the cursor right next to the next separator. If no next - separator is found, moves the cursor to the end of the input.""" - position = self._template.next_separator_position() - if position is None: - self.cursor_position = len(self._template.mask) + separator is found, moves the cursor to the end of the input. + + Args: + select: If `True`, select the text between the old and new cursor positions. + """ + separator_position = self._template.next_separator_position() + if separator_position is None: + target = len(self._template.mask) + else: + target = separator_position + 1 + + if select: + start, _ = self.selection + self.selection = Selection(start, target) else: - self.cursor_position = position + 1 + self.cursor_position = target def action_delete_right(self) -> None: """Delete one character at the current cursor position."""