Skip to content

Conversation

@TomJGooding
Copy link
Collaborator

@TomJGooding TomJGooding commented Dec 15, 2025

When text selection was added to the Input widget in #5340, it was unfortunately overlooked that MaskedInput inherits from this widget.

Please review the following checklist.

  • Docstrings on all new or modified functions / classes
  • Updated documentation
  • Updated CHANGELOG.md (where appropriate)

Fix `MaskedInput` not highlighting the selected text.

Fixes Textualize#5495
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.
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.
When text selection was added to the `Input` widget in Textualize#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 Textualize#5493
Fix `MaskedInput` method override signatures missing the `select`
parameter.
Fix a bad separation of concerns where `MaskedInput` defers to its
template to move the cursor.
Fix bindings like `shift+right` that should move the cursor and select
not selecting text in `MaskedInput`.
Add tests for replacing selected text in the `MaskedInput`.
@TomJGooding
Copy link
Collaborator Author

TomJGooding commented Dec 17, 2025

A MaskedInput can end up something like this after deleting sections:

image

After selecting all text - for example on focus or pressing shift+end - currently the empty text isn't highlighted:

image

This is confusing since it suggests the selection starts at 'E' rather than the start of the input. But after pressing a key to replace the selected text:

image

I think the empty text should also be highlighted to accurately reflect the current selection.


EDIT: I'm a bit rusty on how styles are applied with component classes so finding this harder than I expected!

This simple change will highlight the empty text:

diff --git a/src/textual/widgets/_masked_input.py b/src/textual/widgets/_masked_input.py
index 6013f775e..4ea315d3a 100644
--- a/src/textual/widgets/_masked_input.py
+++ b/src/textual/widgets/_masked_input.py
@@ -637,3 +637,3 @@ class MaskedInput(Input, can_focus=True):
                 selection_style = self.get_component_rich_style("input--selection")
-                result.stylize_before(selection_style, start, end)
+                result.stylize(selection_style, start, end)
image

But I don't think that's the correct solution?

Ideally we still want the indicate where the text is empty, so the selection looks something like this (where 'X' is the placeholder character to make it more obvious):

image

I created this mockup using Rich, but I'm struggling to replicate similar styling in Textual.

Mockup code
from rich import print
from rich.box import HEAVY
from rich.panel import Panel
from rich.style import Style
from rich.text import Text

base_style = Style(color="#e0e0e0", bgcolor="#272727")
selection_style = Style(bgcolor="#2d4e74")
placeholder_style = Style(color="#797979")

input_text = Text("XXXXE-XXXXJ-XXXXO-XXXXT")

for index, char in enumerate(input_text.plain):
    if char == "X":
        input_text.stylize(placeholder_style, index, index + 1)

selection = (0, len(input_text))
start, end = selection
input_text.stylize_before(selection_style, start, end)

cursor = end
input_text.pad_right(1)
input_text.stylize("reverse", cursor, cursor + 1)

input_widget = Panel(
    input_text,
    box=HEAVY,
    style=base_style,
    border_style="#ba3c5b",
)

print(input_widget)

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.
@TomJGooding
Copy link
Collaborator Author

There's still an issue to tackle when the MaskedInput starts with a separator, as reported in #6280.

The problem is that the design of this widget never accounted for this. Even before the problems when text selection was added, pressing home then typing would crash with an assertion error.

Here's an example MaskedInput for a US phone number on the current branch now the selection is highlighted:

image
Example code
from textual.app import App, ComposeResult
from textual.widgets import MaskedInput


class ExampleApp(App):
    def compose(self) -> ComposeResult:
        yield MaskedInput("(999) 999-9999;_")


if __name__ == "__main__":
    app = ExampleApp()
    app.run()

The problems start because, unlike other masked inputs where the initial value is empty, the value starts with the first separator. Before the user even begins interacting with the input, it is already flagged as invalid and there's this strange selection. Trying to start typing will now crash with the assertion error mentioned above.

The reason is that the MaskedInput is designed to automatically insert separators. This works in most cases because the widget takes care to ensure the cursor is never on a separator, but obviously never accounted for when the template mask begins with a separator such as a phone number.

I'm really not sure what the correct solution is here.

I tried checking other examples of masked inputs for comparison, but Textual's widget seems very different where it restricts editing based on the current value length.

For example, here's a similar masked input in Qt (which I believe Textual's widget took some inspiration from). This must have some special handling when the mask begins with a separator, but I don't think would work in the MaskedInput since Qt's widget allows navigating anywhere in the template.

image image image
Example code
import sys

from PySide6.QtWidgets import QApplication, QDialog, QLabel, QLineEdit, QVBoxLayout


class PhoneDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

        layout = QVBoxLayout()

        phone_label = QLabel("Phone Number")
        layout.addWidget(phone_label)

        self.phone_input = QLineEdit()
        self.phone_input.setInputMask("(999) 999-9999;_")
        layout.addWidget(self.phone_input)

        self.setLayout(layout)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    dialog = PhoneDialog()
    dialog.show()
    sys.exit(app.exec())

Any suggestions for resolving this issue would be welcome!

@James-San
Copy link

James-San commented Dec 19, 2025

Hi since this appears to be one I found,
I am no expert on inner workings of textual, but I assume normally it would check when adding character whether next one is mask and then skip over it. Maybe as widget is initialized (or focused) it should perform similar check hardcoded and just advance cursor ? Granted this is just a loose idea, based on how I think it works not that I checked inner code. Hope this helps. If I find some ways to overcome this myself I'll let you know.

Edit1:
Okay did a bit of digging around - cusor positions seem to always start at 1 for some strange reason in case template is starting with masked character. That may be an issue. Going to look into regular input now.

Edit2:
Yes, for normal input cursor starts of with cursor at 0 and advances to 1. For the problem template it starts from 1 and advances nowhere if you put + there. It seems wrong since it should start from 1 but have + rendered or start from 0 and require pressing +.

Edit 3:
Checked for non-problematic template - there it starts from 0. So template vs value interaction is miscounting something in case of template starting from separator.

Edit 4:
Seems to be the cursor_position is set to 1 by constructor of MaskedInput ( as it should ) but then somehow, insert_text_at_cursor is ommited from being called first time a character is typed.

Edit 5:
Seems that the issue is that right as the field is focused first time - it has a deafult selection of first field which makes selection.is_empty evaluate to False ? if you press anything, then character that mask expects and then backspace, it ends up with proper separator, and selection.is_empty evaluating to true. That seems to be cause of the problem

Edit 6:
Okay so I think least invasive to current selection model would be if replace method took into account that it shouldn't replace the separator at start ( which it currently obviously ignores ).

@TomJGooding
Copy link
Collaborator Author

TomJGooding commented Dec 20, 2025

@James-San Thanks for your comment, your input on this issue would be really helpful!

cusor positions seem to always start at 1 for some strange reason in case template is starting with masked character.

When the template mask begins with a separator, you would expect the cursor position to start at 1?

The problem is that this separator is automatically inserted to advance the cursor position (see above).

Currently you don't realise the select_on_focus behaviour as MaskedInput doesn't highlight the selected text. But when you start typing, you're actually replacing the first separator, hence the issue.

Since the initial separator means the value isn't empty, this also results in the input already flagged as invalid before the user even begins interacting with the widget.

This is why I'm struggling to find a solution: when the template mask begins with a separator, what's the input value and the resulting selection/cursor position?

@James-San
Copy link

Yes I can see why that might be an issue. I'm playing around with the replace algorithm from your commit to make it work but I think it needs to keep multiple index. Basically you have to scan replacement text char by char and then:

  1. replacement char is separator and template has same separator - consume from both streams
  2. replacement char is not a separator and template has separator - lookahead until not-separator in template and patternmatch, consume one char from replacement stream and as many as you had to lookahead from template
  3. both replacement char and template aren't separators - pattern match and consume from both
  4. replacement and template separators differ - return None

when patternmatching you of course return None as well.

Something like that logic. It's kinda lexer-like in a way. Or finite state automaton.

@TomJGooding
Copy link
Collaborator Author

Sorry but I'm not sure I follow, Putting aside the replace algorithm for the moment, how would this resolve an empty value when it starts with a separator?

@James-San
Copy link

So the issue is that by default there is selection on focus but field is empty. Thus when you don't clear selection before trying input - it will throw assertion error in this version right? But that's because it tries to replace that first character of string and finds separator where it wouldn't expect it.

Now with the idea above, since it tries to check if user didn't enter valid character omitting the separator - it will no longer crash.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MaskedInput does not highlight the selected text MaskedInput ValueError Value does not match template

2 participants