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 @@
+
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(