From 10c44670fe18fda1dea286c485396d6e15606f1b Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:48:00 +0000 Subject: [PATCH 01/14] fix(masked input): highlight selected text Fix `MaskedInput` not highlighting the selected text. Fixes #5495 --- src/textual/widgets/_masked_input.py | 19 ++- ...test_masked_input_highlights_selection.svg | 153 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 20 +++ 3 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_masked_input_highlights_selection.svg diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index a48ef9b60b..d66c3401ed 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -572,12 +572,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) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_masked_input_highlights_selection.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_masked_input_highlights_selection.svg new file mode 100644 index 0000000000..4b609be24a --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_masked_input_highlights_selection.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MaskedInputApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +1230-0000-0000-0000 +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 4eba817cdd..2f3273fa41 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -41,6 +41,7 @@ ListView, Log, Markdown, + MaskedInput, OptionList, Placeholder, ProgressBar, @@ -4800,3 +4801,22 @@ async def run_before(pilot: Pilot) -> None: await pilot.press("ctrl+v") assert snap_compare(TextAreaApp(), run_before=run_before) + + +def test_masked_input_highlights_selection(snap_compare) -> None: + """Regression test for https://github.com/Textualize/textual/issues/5495 + + You should see a MaskedInput where the selection is highlighted. + """ + + class MaskedInputApp(App): + def compose(self) -> ComposeResult: + yield MaskedInput( + template="9999-9999-9999-9999;0", + value="123" + ) + + async def run_before(pilot): + pilot.app.query_one(MaskedInput).cursor_blink = False + + assert snap_compare(MaskedInputApp(), run_before=run_before) From 3a0be3e3b8168ee15bfb865e31c00995877392a9 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:10:07 +0000 Subject: [PATCH 02/14] docs(masked input): fix missing select_on_focus arg in 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 d66c3401ed..85d5666956 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -474,6 +474,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 067fcd172039bf85f2f095376a6c29a60b451497 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:03:31 +0000 Subject: [PATCH 03/14] test(masked input): add test for overwrite typing While attempting to fix some bugs in the `MaskedInput`, I managed to break the overwrite typing without any tests failing. Add a test for the overwrite typing to prevent regressions. --- tests/test_masked_input.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_masked_input.py b/tests/test_masked_input.py index 72b4d9dc09..82c9eeae4e 100644 --- a/tests/test_masked_input.py +++ b/tests/test_masked_input.py @@ -94,6 +94,39 @@ async def test_editing(): assert input.cursor_position == len(serial) +async def test_overwrite_typing(): + app = InputApp("9999-9999-9999-9999;0") + async with app.run_test() as pilot: + input = app.query_one(MaskedInput) + input.value = "0000-99" + input.action_home() + + await pilot.press("1", "2", "3") + assert input.cursor_position == 3 + assert input.value == "1230-99" + + await pilot.press("4") + assert input.cursor_position == 5 + assert input.value == "1234-99" + + await pilot.press("0", "0") + assert input.cursor_position == 7 + assert input.value == "1234-00" + + await pilot.press("7", "8") + assert input.cursor_position == 10 + assert input.value == "1234-0078-" + + await pilot.press("left", "left") + await pilot.press("backspace", "backspace") + assert input.cursor_position == 5 + assert input.value == "1234- 78" + + await pilot.press("5", "6") + assert input.cursor_position == 7 + assert input.value == "1234-5678" + + async def test_key_movement_actions(): serial = "ABCDE-FGHIJ-KLMNO-PQRST" app = InputApp(">NNNNN-NNNNN-NNNNN-NNNNN;_") From f0a23a8b88ef3869a6b30e2420b889a16bab4ce2 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:29:53 +0000 Subject: [PATCH 04/14] fix(masked input): fix insert_text_at_cursor return type --- 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 85d5666956..6c7c3635ee 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 a18f82e6be39886ae74d666ce89eb59beede1e8b Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Sat, 13 Dec 2025 22:18:00 +0000 Subject: [PATCH 05/14] refactor(masked input): add _Template.replace method Add `_Template.replace` method and move all the code from `insert_text_at_cursor` to this new method. This refactor should help clarify some current bugs in the `MaskedInput` related to inserting/replacing text. --- src/textual/widgets/_masked_input.py | 52 ++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index 6c7c3635ee..ad6b9fe5dd 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -200,19 +200,23 @@ 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) -> 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. + def replace(self, text: str, start: int, end: int) -> tuple[str, int] | None: + """Replace the text between the start and end locations with the given text. + If not present in `text`, any expected separator is automatically inserted at the correct position. Args: - text: The text to be inserted. + text: Text to replace the existing text with. + start: Start index to replace (inclusive). + end: End index to replace (inclusive). Returns: A tuple in the form `(value, cursor_position)` with the new control value and current cursor position if `text` matches the template, None otherwise. """ value = self.input.value - cursor_position = self.input.cursor_position + start, end = sorted((max(0, start), min(len(value), end))) + + template_len = len(self.template) separators = set( [ char_definition.char @@ -220,6 +224,9 @@ def insert_text_at_cursor(self, text: str) -> tuple[str, int] | None: if _CharFlags.SEPARATOR in char_definition.flags ] ) + + cursor_position = start + for char in text: if char in separators: if char == self.next_separator(cursor_position): @@ -241,21 +248,54 @@ def insert_text_at_cursor(self, text: str) -> tuple[str, int] | None: ) cursor_position += 1 continue - if cursor_position >= len(self.template): + + if cursor_position >= template_len: break + char_definition = self.template[cursor_position] assert _CharFlags.SEPARATOR not in char_definition.flags + if not char_definition.pattern.match(char): return None + if _CharFlags.LOWERCASE in char_definition.flags: char = char.lower() elif _CharFlags.UPPERCASE in char_definition.flags: char = char.upper() + value = value[:cursor_position] + char + value[cursor_position + 1 :] cursor_position += 1 value, cursor_position = self.insert_separators(value, cursor_position) + return value, cursor_position + def insert(self, text: str, index: int) -> tuple[str, int] | None: + """Inserts `text` at the given index. If not present in `text`, any expected separator is automatically + inserted at the correct position. + + Args: + text: The text to be inserted. + index: Index to insert the text at (inclusive). + + Returns: + A tuple in the form `(value, cursor_position)` with the new control value and current cursor position if + `text` matches the template, None otherwise. + """ + return self.replace(text, index, index) + + 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. + + Args: + text: The text to be inserted. + + Returns: + A tuple in the form `(value, cursor_position)` with the new control value and current cursor position if + `text` matches the template, None otherwise. + """ + return self.insert(text, self.input.cursor_position) + def move_cursor(self, delta: int) -> None: """Moves the cursor position by `delta` characters, skipping separators if running over them. From ab09e4f6434efd30c814b9eb0db1d218a46517ea Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:35:26 +0000 Subject: [PATCH 06/14] fix(masked input): fix replacing selected text When text selection was added to the `Input` widget in #5340, it was unfortunately overlooked that `MaskedInput` inherits from this widget. Add an override for the `MaskedInput.replace` method to ensure proper functionality when replacing selecting text. Fixes https://github.com/Textualize/textual/issues/5493 --- src/textual/widgets/_masked_input.py | 38 +++++++++++++++++++++++----- tests/test_masked_input.py | 18 +++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index ad6b9fe5dd..b70ea29453 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -216,6 +216,11 @@ def replace(self, text: str, start: int, end: int) -> tuple[str, int] | None: value = self.input.value start, end = sorted((max(0, start), min(len(value), end))) + if not text and end == len(value): + new_value = value[:start] + cursor_position = start + return new_value, cursor_position + template_len = len(self.template) separators = set( [ @@ -225,6 +230,9 @@ def replace(self, text: str, start: int, end: int) -> tuple[str, int] | None: ] ) + empty_text = self.empty_mask[start:end] + new_value = f"{value[:start]}{empty_text}{value[end:]}" + cursor_position = start for char in text: @@ -241,10 +249,10 @@ def replace(self, text: str, start: int, end: int) -> tuple[str, int] | None: char = self.template[cursor_position].char else: char = " " - value = ( - value[:cursor_position] + new_value = ( + new_value[:cursor_position] + char - + value[cursor_position + 1 :] + + new_value[cursor_position + 1 :] ) cursor_position += 1 continue @@ -263,11 +271,15 @@ def replace(self, text: str, start: int, end: int) -> tuple[str, int] | None: elif _CharFlags.UPPERCASE in char_definition.flags: char = char.upper() - value = value[:cursor_position] + char + value[cursor_position + 1 :] + new_value = ( + new_value[:cursor_position] + char + new_value[cursor_position + 1 :] + ) cursor_position += 1 - value, cursor_position = self.insert_separators(value, cursor_position) + new_value, cursor_position = self.insert_separators( + new_value, cursor_position + ) - return value, cursor_position + return new_value, cursor_position def insert(self, text: str, index: int) -> tuple[str, int] | None: """Inserts `text` at the given index. If not present in `text`, any expected separator is automatically @@ -661,6 +673,20 @@ def insert_text_at_cursor(self, text: str) -> None: else: self.restricted() + def replace(self, text: str, start: int, end: int) -> None: + """Replace the text between the start and end locations with the given text. + + Args: + text: Text to replace the existing text with. + start: Start index to replace (inclusive). + end: End index to replace (inclusive). + """ + new_value = self._template.replace(text, start, end) + if new_value is not None: + self.value, self.cursor_position = new_value + else: + self.restricted() + def clear(self) -> None: """Clear the masked input.""" self.value, self.cursor_position = self._template.insert_separators("", 0) diff --git a/tests/test_masked_input.py b/tests/test_masked_input.py index 82c9eeae4e..160a519381 100644 --- a/tests/test_masked_input.py +++ b/tests/test_masked_input.py @@ -254,3 +254,21 @@ async def test_digits_required(): await pilot.press("a", "1") assert input.value == "1" assert not input.is_valid + + +async def test_replace_selection_with_invalid_value(): + """Regression test for https://github.com/Textualize/textual/issues/5493""" + + class MaskedInputApp(App): + def compose(self) -> ComposeResult: + yield MaskedInput( + template="9999-99-99", + value="2025-12", + ) + + app = MaskedInputApp() + async with app.run_test() as pilot: + input = app.query_one(MaskedInput) + assert input.selection == (0, len(input.value)) # Sanity check + await pilot.press("a") + assert input.value == "2025-12" From 48b9b1867f9e2d470337916c00f22f6f9f9a5fda Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:42:32 +0000 Subject: [PATCH 07/14] refactor: refactor to simplify masked input tests --- tests/test_masked_input.py | 103 +++++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 27 deletions(-) diff --git a/tests/test_masked_input.py b/tests/test_masked_input.py index 160a519381..87cbc50b99 100644 --- a/tests/test_masked_input.py +++ b/tests/test_masked_input.py @@ -10,16 +10,24 @@ InputEvent = Union[MaskedInput.Changed, MaskedInput.Submitted] -class InputApp(App[None]): - def __init__(self, template: str, placeholder: str = ""): +class MaskedInputApp(App[None]): + def __init__( + self, + template: str, + value: str | None = None, + select_on_focus: bool = True, + ): super().__init__() self.messages: list[InputEvent] = [] self.template = template - self.placeholder = placeholder + self.value = value + self.select_on_focus = select_on_focus def compose(self) -> ComposeResult: yield MaskedInput( - template=self.template, placeholder=self.placeholder, select_on_focus=False + template=self.template, + value=self.value, + select_on_focus=self.select_on_focus, ) @on(MaskedInput.Changed) @@ -29,7 +37,10 @@ def on_changed_or_submitted(self, event: InputEvent) -> None: async def test_missing_required(): - app = InputApp(">9999-99-99") + app = MaskedInputApp( + template=">9999-99-99", + select_on_focus=False, + ) async with app.run_test() as pilot: input = app.query_one(MaskedInput) input.value = "2024-12" @@ -48,7 +59,10 @@ async def test_missing_required(): async def test_valid_required(): - app = InputApp(">9999-99-99") + app = MaskedInputApp( + template=">9999-99-99", + select_on_focus=False, + ) async with app.run_test() as pilot: input = app.query_one(MaskedInput) input.value = "2024-12-31" @@ -59,7 +73,10 @@ async def test_valid_required(): async def test_missing_optional(): - app = InputApp(">9999-99-00") + app = MaskedInputApp( + template=">9999-99-00", + select_on_focus=False, + ) async with app.run_test() as pilot: input = app.query_one(MaskedInput) input.value = "2024-12" @@ -71,7 +88,10 @@ async def test_missing_optional(): async def test_editing(): serial = "ABCDE-FGHIJ-KLMNO-PQRST" - app = InputApp(">NNNNN-NNNNN-NNNNN-NNNNN;_") + app = MaskedInputApp( + template=">NNNNN-NNNNN-NNNNN-NNNNN;_", + select_on_focus=False, + ) async with app.run_test() as pilot: input = app.query_one(MaskedInput) await pilot.press("A", "B", "C", "D") @@ -95,7 +115,10 @@ async def test_editing(): async def test_overwrite_typing(): - app = InputApp("9999-9999-9999-9999;0") + app = MaskedInputApp( + template="9999-9999-9999-9999;0", + select_on_focus=False, + ) async with app.run_test() as pilot: input = app.query_one(MaskedInput) input.value = "0000-99" @@ -129,7 +152,10 @@ async def test_overwrite_typing(): async def test_key_movement_actions(): serial = "ABCDE-FGHIJ-KLMNO-PQRST" - app = InputApp(">NNNNN-NNNNN-NNNNN-NNNNN;_") + app = MaskedInputApp( + template=">NNNNN-NNNNN-NNNNN-NNNNN;_", + select_on_focus=False, + ) async with app.run_test(): input = app.query_one(MaskedInput) input.value = serial @@ -149,7 +175,10 @@ async def test_key_movement_actions(): async def test_key_modification_actions(): serial = "ABCDE-FGHIJ-KLMNO-PQRST" - app = InputApp(">NNNNN-NNNNN-NNNNN-NNNNN;_") + app = MaskedInputApp( + template=">NNNNN-NNNNN-NNNNN-NNNNN;_", + select_on_focus=False, + ) async with app.run_test() as pilot: input = app.query_one(MaskedInput) input.value = serial @@ -186,7 +215,10 @@ async def test_key_modification_actions(): async def test_cursor_word_right_after_last_separator(): - app = InputApp(">NNN-NNN-NNN-NNNNN;_") + app = MaskedInputApp( + template=">NNN-NNN-NNN-NNNNN;_", + select_on_focus=False, + ) async with app.run_test(): input = app.query_one(MaskedInput) input.value = "123-456-789-012" @@ -196,7 +228,10 @@ async def test_cursor_word_right_after_last_separator(): async def test_case_conversion_meta_characters(): - app = InputApp("NN<-N!N>N") + app = MaskedInputApp( + template="NN<-N!N>N", + select_on_focus=False, + ) async with app.run_test() as pilot: input = app.query_one(MaskedInput) await pilot.press("a", "B", "C", "D", "e") @@ -205,7 +240,10 @@ async def test_case_conversion_meta_characters(): async def test_case_conversion_override(): - app = InputApp(">- ComposeResult: - yield MaskedInput( - template="9999-99-99", - value="2025-12", - ) - - app = MaskedInputApp() + app = MaskedInputApp( + template="9999-99-99", + value="2025-12", + ) async with app.run_test() as pilot: input = app.query_one(MaskedInput) assert input.selection == (0, len(input.value)) # Sanity check From 842c81de88c3458e7680754fb064a0bd7c32a416 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:18:43 +0000 Subject: [PATCH 08/14] fix(masked input): fix method override signatures Fix `MaskedInput` method override signatures missing the `select` parameter. --- src/textual/widgets/_masked_input.py | 40 +++++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index b70ea29453..11a418b460 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -691,21 +691,37 @@ def clear(self) -> None: """Clear the masked input.""" self.value, self.cursor_position = self._template.insert_separators("", 0) - def action_cursor_left(self) -> None: - """Move the cursor one position to the left; separators are skipped.""" + def action_cursor_left(self, select: bool = False) -> None: + """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) - def action_cursor_right(self) -> None: - """Move the cursor one position to the right; separators are skipped.""" + def action_cursor_right(self, select: bool = False) -> None: + """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) - def action_home(self) -> None: - """Move the cursor to the start of the input.""" + def action_home(self, select: bool = False) -> None: + """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)) - 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.""" + 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) else: @@ -714,9 +730,13 @@ 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.""" + 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. + """ position = self._template.next_separator_position() if position is None: self.cursor_position = len(self._template.mask) From d44c5db676a324a8cdac3c2461e9c6a11cdd5eb8 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:05:04 +0000 Subject: [PATCH 09/14] refactor(masked input): move cursor movement responsibility to widget Fix a bad separation of concerns where `MaskedInput` defers to its template to move the cursor. --- src/textual/widgets/_masked_input.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index 11a418b460..d4827107c1 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -308,13 +308,16 @@ def insert_text_at_cursor(self, text: str) -> tuple[str, int] | None: """ return self.insert(text, self.input.cursor_position) - def move_cursor(self, delta: int) -> None: + def move_cursor(self, delta: int) -> int: """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. + + Returns: + The new cursor position. """ cursor_position = self.input.cursor_position if delta < 0 and all( @@ -323,7 +326,8 @@ def move_cursor(self, delta: int) -> None: for char_definition in self.template[:cursor_position] ] ): - return + return cursor_position + cursor_position += delta while ( (cursor_position >= 0) @@ -331,7 +335,8 @@ 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 + + return cursor_position def delete_at_position(self, position: int | None = None) -> None: """Deletes character at `position`. @@ -658,7 +663,8 @@ async def _on_click(self, event: events.Click) -> None: """Ensure clicking on value does not leave cursor on a separator.""" await super()._on_click(event) if self._template.at_separator(): - self._template.move_cursor(1) + cursor_position = self._template.move_cursor(1) + self.cursor_position = cursor_position 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. @@ -697,7 +703,8 @@ def action_cursor_left(self, select: bool = False) -> None: Args: select: If `True`, select the text to the left of the cursor. """ - self._template.move_cursor(-1) + cursor_position = self._template.move_cursor(-1) + self.cursor_position = cursor_position def action_cursor_right(self, select: bool = False) -> None: """Move the cursor one position to the right; separators are skipped. @@ -705,7 +712,8 @@ def action_cursor_right(self, select: bool = False) -> None: Args: select: If `True`, select the text to the right of the cursor. """ - self._template.move_cursor(1) + cursor_position = self._template.move_cursor(1) + self.cursor_position = cursor_position def action_home(self, select: bool = False) -> None: """Move the cursor to the start of the input. @@ -713,7 +721,8 @@ def action_home(self, select: bool = False) -> None: Args: select: If `True`, select the text between the old and new cursor positions. """ - self._template.move_cursor(-len(self.template)) + cursor_position = self._template.move_cursor(-len(self.template)) + self.cursor_position = cursor_position def action_cursor_left_word(self, select: bool = False) -> None: """Move the cursor left next to the previous separator. If no previous @@ -765,7 +774,8 @@ def action_delete_left(self) -> None: if self.cursor_position <= 0: # Cursor at the start, so nothing to delete return - self._template.move_cursor(-1) + cursor_position = self._template.move_cursor(-1) + self.cursor_position = cursor_position self._template.delete_at_position() def action_delete_left_word(self) -> None: From ac817e3fe392615f640a4220e9e2eb7e699e5373 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:43:11 +0000 Subject: [PATCH 10/14] test(masked input): import future annotations --- tests/test_masked_input.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_masked_input.py b/tests/test_masked_input.py index 87cbc50b99..53f7e15bfb 100644 --- a/tests/test_masked_input.py +++ b/tests/test_masked_input.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Union import pytest From f570a9b1452449adfd5ff5fc7e024404319271ec Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:04:51 +0000 Subject: [PATCH 11/14] fix(masked input): fix bindings to move and select Fix bindings like `shift+right` that should move the cursor and select not selecting text in `MaskedInput`. --- src/textual/widgets/_masked_input.py | 59 ++++++++++++++++++++++------ tests/test_masked_input.py | 38 ++++++++++++++++++ 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index d4827107c1..6013f775eb 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -18,7 +18,7 @@ from textual.reactive import Reactive, var from textual.validation import ValidationResult, Validator -from textual.widgets._input import Input +from textual.widgets._input import Input, Selection InputValidationOn = Literal["blur", "changed", "submitted"] """Possible messages that trigger input validation.""" @@ -703,8 +703,15 @@ def action_cursor_left(self, select: bool = False) -> None: Args: select: If `True`, select the text to the left of the cursor. """ + start, end = self.selection cursor_position = self._template.move_cursor(-1) - self.cursor_position = cursor_position + if select: + self.selection = Selection(start, cursor_position) + else: + if self.selection.is_empty: + self.cursor_position = cursor_position + else: + self.cursor_position = min(start, end) def action_cursor_right(self, select: bool = False) -> None: """Move the cursor one position to the right; separators are skipped. @@ -712,8 +719,15 @@ def action_cursor_right(self, select: bool = False) -> None: Args: select: If `True`, select the text to the right of the cursor. """ + start, end = self.selection cursor_position = self._template.move_cursor(1) - self.cursor_position = cursor_position + if select: + self.selection = Selection(start, cursor_position) + else: + if self.selection.is_empty: + self.cursor_position = cursor_position + else: + self.cursor_position = max(start, end) def action_home(self, select: bool = False) -> None: """Move the cursor to the start of the input. @@ -722,7 +736,10 @@ def action_home(self, select: bool = False) -> None: select: If `True`, select the text between the old and new cursor positions. """ cursor_position = self._template.move_cursor(-len(self.template)) - self.cursor_position = cursor_position + if select: + self.selection = Selection(self.cursor_position, cursor_position) + else: + self.cursor_position = cursor_position def action_cursor_left_word(self, select: bool = False) -> None: """Move the cursor left next to the previous separator. If no previous @@ -732,12 +749,22 @@ def action_cursor_left_word(self, select: bool = False) -> None: 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 is None: + cursor_position = 0 + else: + cursor_position = separator_position + 1 + + if select: + start, _ = self.selection + self.selection = Selection(start, cursor_position) + else: + self.cursor_position = cursor_position def action_cursor_right_word(self, select: bool = False) -> None: """Move the cursor right next to the next separator. If no next @@ -746,11 +773,17 @@ def action_cursor_right_word(self, select: bool = False) -> None: Args: select: If `True`, select the text between the old and new cursor positions. """ - position = self._template.next_separator_position() - if position is None: - self.cursor_position = len(self._template.mask) + separator_position = self._template.next_separator_position() + if separator_position is None: + cursor_position = len(self._template.mask) else: - self.cursor_position = position + 1 + cursor_position = separator_position + 1 + + if select: + start, _ = self.selection + self.selection = Selection(start, cursor_position) + else: + self.cursor_position = cursor_position def action_delete_right(self) -> None: """Delete one character at the current cursor position.""" diff --git a/tests/test_masked_input.py b/tests/test_masked_input.py index 53f7e15bfb..4cba9711c9 100644 --- a/tests/test_masked_input.py +++ b/tests/test_masked_input.py @@ -323,3 +323,41 @@ async def test_replace_selection_with_invalid_value(): assert input.selection == (0, len(input.value)) # Sanity check await pilot.press("a") assert input.value == "2025-12" + + +async def test_movement_actions_with_select(): + app = MaskedInputApp( + template=">NNNNN-NNNNN-NNNNN-NNNNN;_", + value="ABCDE-FGHIJ-KLMNO-PQRST", + select_on_focus=False, + ) + async with app.run_test(): + input = app.query_one(MaskedInput) + + input.action_home(select=True) + assert input.selection == (len(input.value), 0) + + input.action_cursor_left() + assert input.selection.is_empty + assert input.cursor_position == 0 + + input.action_cursor_right_word(select=True) + assert input.selection == (0, 6) + + input.action_cursor_right() + assert input.selection.is_empty + assert input.cursor_position == 6 + + input.action_cursor_left(select=True) + assert input.selection == (6, 4) + + input.action_cursor_left() + input.action_cursor_right(select=True) + assert input.selection == (4, 6) + + input.action_end(select=True) + assert input.selection == (6, len(input.value)) + + input.action_cursor_right() + input.action_cursor_left_word(select=True) + assert input.selection == (len(input.value), 18) From cb0b8891a18865dbd48b0c2c15266bfecaf0fa4c Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:41:39 +0000 Subject: [PATCH 12/14] test(masked input): add test for replacing selection Add tests for replacing selected text in the `MaskedInput`. --- tests/test_masked_input.py | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_masked_input.py b/tests/test_masked_input.py index 4cba9711c9..0af432f34a 100644 --- a/tests/test_masked_input.py +++ b/tests/test_masked_input.py @@ -361,3 +361,55 @@ async def test_movement_actions_with_select(): input.action_cursor_right() input.action_cursor_left_word(select=True) assert input.selection == (len(input.value), 18) + + +async def test_replace_selection(): + app = MaskedInputApp( + template="NNNNN-NNNNN-NNNNN-NNNNN;_", + value="ABCDE-FGHIJ-KLMNO-PQRST", + select_on_focus=False, + ) + async with app.run_test() as pilot: + input = app.query_one(MaskedInput) + + input.cursor_position = 0 + input.action_cursor_right(select=True) + await pilot.press("x") + assert input.value == "xBCDE-FGHIJ-KLMNO-PQRST" + assert input.selection.is_empty + assert input.cursor_position == 1 + + input.cursor_position = 3 + input.action_cursor_left(select=True) + await pilot.press("x") + assert input.value == "xBxDE-FGHIJ-KLMNO-PQRST" + assert input.selection.is_empty + assert input.cursor_position == 3 + + input.cursor_position = 6 + input.action_cursor_left(select=True) + await pilot.press("x") + assert input.value == "xBxDx-FGHIJ-KLMNO-PQRST" + assert input.selection.is_empty + assert input.cursor_position == 6 + + input.cursor_position = 9 + input.action_cursor_left_word(select=True) + await pilot.press("x") + assert input.value == "xBxDx-x IJ-KLMNO-PQRST" + assert input.selection.is_empty + assert input.cursor_position == 7 + + input.cursor_position = 15 + input.action_cursor_right_word(select=True) + await pilot.press("x") + assert input.value == "xBxDx-x IJ-KLMx -PQRST" + assert input.selection.is_empty + assert input.cursor_position == 16 + + input.cursor_position = 9 + input.action_home(select=True) + await pilot.press("a") + assert input.value == "a - IJ-KLMx -PQRST" + assert input.selection.is_empty + assert input.cursor_position == 1 From 42b2a82b97cdebdb12e5f0cd89fc9a2ad40e3827 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:31:12 +0000 Subject: [PATCH 13/14] fix(masked input): fix replacing selection up to input end When _deleting_ text in a `MaskedInput`, this removes any empty text at the end of the input value. For example, deleting everything to the right in 'ABCDE-FGHIJ-KLMNO-PQRST' results in the value 'A'. Currently _replacing_ text will leave behind this empty text. For example, selecting all text and pressing 'A' instead results in the value 'A - - - '. Fix replacing text in the `MaskedInput` to remove any empty text at the end of the input value. --- src/textual/widgets/_masked_input.py | 6 ++++++ tests/test_masked_input.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py index 6013f775eb..65b55e2a6e 100644 --- a/src/textual/widgets/_masked_input.py +++ b/src/textual/widgets/_masked_input.py @@ -279,6 +279,12 @@ def replace(self, text: str, start: int, end: int) -> tuple[str, int] | None: new_value, cursor_position ) + if ( + new_value[cursor_position:] + == self.empty_mask[cursor_position : len(new_value)] + ): + new_value = new_value[:cursor_position] + return new_value, cursor_position def insert(self, text: str, index: int) -> tuple[str, int] | None: diff --git a/tests/test_masked_input.py b/tests/test_masked_input.py index 0af432f34a..a29468cbea 100644 --- a/tests/test_masked_input.py +++ b/tests/test_masked_input.py @@ -413,3 +413,16 @@ async def test_replace_selection(): assert input.value == "a - IJ-KLMx -PQRST" assert input.selection.is_empty assert input.cursor_position == 1 + + input.cursor_position = 13 + input.action_end(select=True) + await pilot.press("x") + assert input.value == "a - IJ-Kx" + assert input.selection.is_empty + assert input.cursor_position == 14 + + input.action_home(select=True) + await pilot.press("x") + assert input.value == "x" + assert input.selection.is_empty + assert input.cursor_position == 1 From 129295bfb71131aea241a4109a44a2a5bf60c1b9 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:10:24 +0000 Subject: [PATCH 14/14] test(masked input): add test for insert jump to next separator While trying to fix some bugs in the `MaskedInput`, I discovered that typing a separator is expected to jump to the next separator, skipping over any empty positions. Currently there aren't any tests covering this jump to separator behaviour, so this adds a test to prevent regressions. --- tests/test_masked_input.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_masked_input.py b/tests/test_masked_input.py index a29468cbea..35a0fd11a1 100644 --- a/tests/test_masked_input.py +++ b/tests/test_masked_input.py @@ -152,6 +152,33 @@ async def test_overwrite_typing(): assert input.value == "1234-5678" +async def test_insert_jump_to_next_separator(): + app = MaskedInputApp( + template="9999-9999-9999-9999;0", + select_on_focus=False, + ) + async with app.run_test() as pilot: + input = app.query_one(MaskedInput) + + # If cursor is at the start, input should not jump to next separator + await pilot.press("-") + assert input.value == "" + assert input.cursor_position == 0 + + await pilot.press("1", "-") + assert input.value == "1 -" + assert input.cursor_position == 5 + + # If previous character is a separator, input should not jump to next separator + await pilot.press("-") + assert input.value == "1 -" + assert input.cursor_position == 5 + + await pilot.press("2", "-") + assert input.value == "1 -2 -" + assert input.cursor_position == 10 + + async def test_key_movement_actions(): serial = "ABCDE-FGHIJ-KLMNO-PQRST" app = MaskedInputApp(