Repository: Textualize/textual
Pull request: Customisable input validation (& validation on blur events)
Fixes #3100.

Problem statement:
Validation on blur
Currently validation is done on input change and submit. I think we also need to do it on blur.

Consider a password check. I find it super irritating when validation shows an error before I'm done typing.

It should probably also be configrable. So the dev can decide when the validation happens.

Hints from issue discussion:

Commit message: Update CHANGELOG.md

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dcf85b86bd..a1169c0914 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 ### Added
 
 - TCSS styles `layer` and `layers` can be strings https://github.com/Textualize/textual/pull/3169
+- `Input` is now validated when focus moves out of it https://github.com/Textualize/textual/pull/3193
+- Attribute `Input.prevent_validation_on` (and `__init__` parameter of the same name) to customise when validation occurs https://github.com/Textualize/textual/pull/3193
 
 ### Changed
 
diff --git a/docs/widgets/input.md b/docs/widgets/input.md
index e1f6191e57..a47da7aafb 100644
--- a/docs/widgets/input.md
+++ b/docs/widgets/input.md
@@ -26,7 +26,8 @@ The example below shows how you might create a simple form using two `Input` wid
 
 You can supply one or more *[validators][textual.validation.Validator]* to the `Input` widget to validate the value.
 
-When the value changes or the `Input` is submitted, all the supplied validators will run.
+All the supplied validators will run when the value changes, the `Input` is submitted, or focus moves _out_ of the `Input`.
+This can be customized via the attribute [`prevent_validation_on`][textual.widgets.Input.prevent_validation_on].
 
 Validation is considered to have failed if *any* of the validators fail.
 
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
index daf298e7aa..5e39f0c0a2 100644
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -221,6 +221,7 @@ def __init__(
         *,
         suggester: Suggester | None = None,
         validators: Validator | Iterable[Validator] | None = None,
+        prevent_validation_on: Iterable[type[Message]] | None = None,
         name: str | None = None,
         id: str | None = None,
         classes: str | None = None,
@@ -236,6 +237,8 @@ def __init__(
             suggester: [`Suggester`][textual.suggester.Suggester] associated with this
                 input instance.
             validators: An iterable of validators that the Input value will be checked against.
+            prevent_validation_on: Message types for which validation shouldn't occur.
+                Validation occurs for input changes and submissions, as well as on blur events.
             name: Optional name for the input widget.
             id: Optional ID for the widget.
             classes: Optional initial classes for the widget.
@@ -254,7 +257,26 @@ def __init__(
         elif validators is None:
             self.validators = []
         else:
-            self.validators = list(validators) or []
+            self.validators = list(validators)
+        self.prevent_validation_on: set[type[Message]] = set(
+            prevent_validation_on or []
+        ) & {self.Changed, self.Submitted, Blur}
+        """Set with events to skip validation on.
+
+        Validation is only performed on blur, when input changes and when it's submitted.
+        Including any of these types of messages in this set will skip validation on
+        these message types.
+
+        Example:
+            This creates an `Input` widget that only gets validated when the value
+            is submitted explicitly:
+
+            ```py
+            from textual.events import Blur
+
+            input = Input(prevent_validation_on=[Blur, Input.Changed])
+            ```
+        """
 
     def _position_to_cell(self, position: int) -> int:
         """Convert an index within the value to cell position."""
@@ -306,8 +328,11 @@ async def _watch_value(self, value: str) -> None:
         if self.styles.auto_dimensions:
             self.refresh(layout=True)
 
-        validation_result = self.validate(value)
-
+        validation_result = (
+            self.validate(value)
+            if self.Changed not in self.prevent_validation_on
+            else None
+        )
         self.post_message(self.Changed(self, value, validation_result))
 
     def validate(self, value: str) -> ValidationResult | None:
@@ -389,6 +414,8 @@ def _on_mount(self, _: Mount) -> None:
 
     def _on_blur(self, _: Blur) -> None:
         self.blink_timer.pause()
+        if Blur not in self.prevent_validation_on:
+            self.validate(self.value)
 
     def _on_focus(self, _: Focus) -> None:
         self.cursor_position = len(self.value)
@@ -578,5 +605,9 @@ async def action_submit(self) -> None:
 
         Normally triggered by the user pressing Enter. This will also run any validators.
         """
-        validation_result = self.validate(self.value)
+        validation_result = (
+            self.validate(self.value)
+            if self.Submitted not in self.prevent_validation_on
+            else None
+        )
         self.post_message(self.Submitted(self, self.value, validation_result))
diff --git a/tests/input/test_input_validation.py b/tests/input/test_input_validation.py
index b02b2e6574..0e6f78261f 100644
--- a/tests/input/test_input_validation.py
+++ b/tests/input/test_input_validation.py
@@ -1,18 +1,21 @@
 from textual import on
 from textual.app import App, ComposeResult
+from textual.events import Blur
 from textual.validation import Number, ValidationResult
 from textual.widgets import Input
 
 
 class InputApp(App):
-    def __init__(self):
+    def __init__(self, prevent_validation_on=None):
         super().__init__()
         self.messages = []
         self.validator = Number(minimum=1, maximum=5)
+        self.prevent_validation_on = prevent_validation_on or set()
 
     def compose(self) -> ComposeResult:
         yield Input(
             validators=self.validator,
+            prevent_validation_on=self.prevent_validation_on,
         )
 
     @on(Input.Changed)
@@ -77,3 +80,47 @@ async def test_input_submitted_message_validation_success():
         await pilot.pause()
         assert len(app.messages) == 2
         assert app.messages[1].validation_result == ValidationResult.success()
+
+
+async def test_on_blur_triggers_validation():
+    app = InputApp()
+    async with app.run_test() as pilot:
+        input = app.query_one(Input)
+        input.focus()
+        input.value = "3"
+        input.remove_class("-valid")
+        app.set_focus(None)
+        await pilot.pause()
+        assert input.has_class("-valid")
+
+
+async def test_prevent_validation_on_changes():
+    app = InputApp([Input.Changed])
+    async with app.run_test() as pilot:
+        assert len(app.messages) == 0
+        app.query_one(Input).value = "3"
+        await pilot.pause()
+        assert len(app.messages) == 1
+        assert app.messages[0].validation_result is None
+
+
+async def test_prevent_validation_on_submission():
+    app = InputApp([Input.Submitted])
+    async with app.run_test() as pilot:
+        await app.query_one(Input).action_submit()
+        await pilot.pause()
+        assert len(app.messages) == 1
+        assert app.messages[0].validation_result is None
+
+
+async def test_prevent_validation_on_blur():
+    app = InputApp([Blur])
+    async with app.run_test() as pilot:
+        input = app.query_one(Input)
+        input.focus()
+        input.value = "3"
+        await pilot.pause()
+        input.remove_class("-valid")
+        app.set_focus(None)
+        await pilot.pause()
+        assert not input.has_class("-valid")


Code review comments:
diff_hunk: @@ -221,6 +221,7 @@ def __init__(
         *,
         suggester: Suggester | None = None,
         validators: Validator | Iterable[Validator] | None = None,
+        prevent_validation_on: Iterable[type[Message]] | None = None,
path: src/textual/widgets/_input.py
text: @user1:
This parameter is negative condition. The dev is saying don't do something on this. This creates a bit of cognitive load where the developer has to reverse what they want in their mind.

Suggest we drop the `prevent_` and `have it simply `validate_on`.

Requiring an iterable of messages could also be confounding. As the typing would accept any Message.

I think this should be an iterable of literals. Something like this:

`validate_on: Iterable[ValidateCondition] | None`

Where 

`ValidateContition = Literal["changed", "submitted", "blur"]`


@author:
Re `Literal`: consider it done.

I disagree with the negative condition & cognitive load argument.
One negation is something reasonable.
Since the default is “everything”, it is sensible to have a parameter that allows ignoring just some, just like with the context manager `prevent`.

@user1:
There is only three options. `validate_on = {"blur", "changed"}` tells me everything I need to know. `prevent_validation_on = {"changed"}` leaves me wondering what the default is.

@author:
See a26307278

diff_hunk: @@ -254,7 +257,26 @@ def __init__(
         elif validators is None:
             self.validators = []
         else:
-            self.validators = list(validators) or []
+            self.validators = list(validators)
+        self.prevent_validation_on: set[type[Message]] = set(
path: src/textual/widgets/_input.py
text: @user1:
A set literal expression may work better here.

```python
{*(prevent_validation_on or []), self.Changed, self.Submitted, Blur}
```