Repository: beeware/toga (Python)

Issue:
Modify invocation order of TextInput on_change and validation
### Discussed in https://github.com/beeware/toga/discussions/2323

Via @cozimus

<div type='discussions-op-text'>

<sup>Originally posted by **cozimus** January  4, 2024</sup>
In this minimum working example I tried to enable/disable a button based on a `is_valid` condition on a TextInput:

``` python
import toga
from toga.validators import Number

class MainApp(toga.App):
    def startup(self):
        self.main_window = toga.MainWindow(title=self.formal_name)
        self.button = toga.Button("submit")
        text_input = toga.TextInput(on_change=self.set_button, validators=[Number()])
        self.main_window.content = toga.Box(children=[text_input, self.button])
        self.main_window.show()

    def set_button(self, widget: toga.TextInput):
        self.button.enabled = widget.is_valid


def main():
    return MainApp("App", "com.example")


if __name__ == "__main__":
    main().main_loop()
```

My goal is to disable the button whenever the text is not valid.
However when I modify the text with a non-number value, the button is still enabled, and it is disabled only when I change again the text.
It seems like the `on_change` handle is triggered **before** the `is_valid` flag update, hence when I call my `set_button` functions, `is_valid` is still as the previous state.

</div>

This *should* be the behavior of validation; the current behavior isn't intentional (or documented as such).

## Suggested implementation

* Refactor the pair of `on_change()` and `_validate()` calls used by every backend into a single (private) `_value_changed()` method on the core class that can be invoked by backends whenever the value changes.
* Add a test to the core API tests that confirms that when the `on_change()` handler is invoked, the `is_valid` status is correct. This validates that the core API provides utilities that validate in the correct order.
* Add a test to the test backend that validates the same. This confirms that all the backends are either using the new private method, or at least have an implementation that is consistent.
* Update the documentation for `on_change` to explicitly define that the field will have been validated when on_change is invoked.



Discussion:
wouldn't it help just to invert the order the functions are called inside the core modules? I tried this and it seems to work
![image](https://github.com/beeware/toga/assets/7837023/d102a386-b40b-4e43-b69b-4609b02b9f73)

Yes- that would, indeed fix the problem- and would be part of the first bullet point in the suggested implementation. I've suggested that refactoring is worthwhile because this is functionality that is common to *every* backend, so we can ensure that if any other change-related behavior occurs (or a new platform is added), the behavior will be consistent.
I'll look into this.



Patch:
diff --git a/android/src/toga_android/widgets/textinput.py b/android/src/toga_android/widgets/textinput.py
index b6c1c90c56..83ecec4241 100644
--- a/android/src/toga_android/widgets/textinput.py
+++ b/android/src/toga_android/widgets/textinput.py
@@ -111,8 +111,7 @@ def is_valid(self):
         return self.native.getError() is None
 
     def _on_change(self):
-        self.interface.on_change()
-        self.interface._validate()
+        self.interface._value_changed()
 
     def _on_confirm(self):
         self.interface.on_confirm()
diff --git a/changes/2325.bugfix.rst b/changes/2325.bugfix.rst
new file mode 100644
index 0000000000..e4833b429b
--- /dev/null
+++ b/changes/2325.bugfix.rst
@@ -0,0 +1,1 @@
+Invocation order of TextInput on_change and validation is now correct.
diff --git a/cocoa/src/toga_cocoa/widgets/textinput.py b/cocoa/src/toga_cocoa/widgets/textinput.py
index ee7f762e98..43f3449fcd 100644
--- a/cocoa/src/toga_cocoa/widgets/textinput.py
+++ b/cocoa/src/toga_cocoa/widgets/textinput.py
@@ -213,8 +213,7 @@ def get_value(self):
 
     def set_value(self, value):
         self.native.stringValue = value
-        self.interface.on_change()
-        self.interface._validate()
+        self.interface._value_changed()
 
     def rehint(self):
         # Height of a text input is known and fixed.
diff --git a/core/src/toga/widgets/textinput.py b/core/src/toga/widgets/textinput.py
index 74a64bebcb..5c207e7edc 100644
--- a/core/src/toga/widgets/textinput.py
+++ b/core/src/toga/widgets/textinput.py
@@ -181,6 +181,11 @@ def _validate(self):
         else:
             self._impl.clear_error()
 
+    def _value_changed(self):
+        """Validate the current value of the widget and invoke the on_change handler."""
+        self._validate()
+        self.on_change()
+
     @property
     def on_confirm(self) -> callable:
         """The handler to invoke when the user accepts the value of the widget,
diff --git a/docs/reference/api/widgets/textinput.rst b/docs/reference/api/widgets/textinput.rst
index dfed425639..f95e64d242 100644
--- a/docs/reference/api/widgets/textinput.rst
+++ b/docs/reference/api/widgets/textinput.rst
@@ -71,6 +71,8 @@ The input can also be provided a list of :ref:`validators <validators>`. A
 validator is a function that will be invoked whenever the content of the input
 changes. The function should return ``None`` if the current value of the input
 is valid; if the current value is invalid, it should return an error message.
+When ``on_change`` is invoked, the field will automatically be validated based on
+specified validators.
 
 Notes
 -----
diff --git a/dummy/src/toga_dummy/widgets/textinput.py b/dummy/src/toga_dummy/widgets/textinput.py
index 6459786ec4..9739286067 100644
--- a/dummy/src/toga_dummy/widgets/textinput.py
+++ b/dummy/src/toga_dummy/widgets/textinput.py
@@ -36,8 +36,7 @@ def is_valid(self):
         return self._get_value("valid")
 
     def simulate_change(self):
-        self.interface.on_change()
-        self.interface._validate()
+        self.interface._value_changed()
 
     def simulate_confirm(self):
         self.interface.on_confirm()
diff --git a/gtk/src/toga_gtk/widgets/textinput.py b/gtk/src/toga_gtk/widgets/textinput.py
index ecce9555d5..8481b255b0 100644
--- a/gtk/src/toga_gtk/widgets/textinput.py
+++ b/gtk/src/toga_gtk/widgets/textinput.py
@@ -16,8 +16,7 @@ def create(self):
         self.native.connect("key-press-event", self.gtk_key_press_event)
 
     def gtk_on_change(self, entry):
-        self.interface.on_change()
-        self.interface._validate()
+        self.interface._value_changed()
 
     def gtk_focus_in_event(self, entry, user_data):
         self.interface.on_gain_focus()
diff --git a/iOS/src/toga_iOS/widgets/textinput.py b/iOS/src/toga_iOS/widgets/textinput.py
index 62a080f041..b9e2875404 100644
--- a/iOS/src/toga_iOS/widgets/textinput.py
+++ b/iOS/src/toga_iOS/widgets/textinput.py
@@ -29,8 +29,7 @@ def textFieldDidBeginEditing_(self, textField) -> None:
 
     @objc_method
     def textFieldDidChange_(self, textField) -> None:
-        self.interface.on_change()
-        self.interface._validate()
+        self.interface._value_changed()
 
     @objc_method
     def textFieldDidEndEditing_(self, textField) -> None:
@@ -127,8 +126,7 @@ def get_value(self):
 
     def set_value(self, value):
         self.native.text = value
-        self.interface.on_change()
-        self.interface._validate()
+        self.interface._value_changed()
 
     def set_alignment(self, value):
         self.native.textAlignment = NSTextAlignment(value)
diff --git a/winforms/src/toga_winforms/widgets/textinput.py b/winforms/src/toga_winforms/widgets/textinput.py
index 2fbd4e45fd..6086ac29ec 100644
--- a/winforms/src/toga_winforms/widgets/textinput.py
+++ b/winforms/src/toga_winforms/widgets/textinput.py
@@ -76,8 +76,7 @@ def rehint(self):
         )
 
     def winforms_text_changed(self, sender, event):
-        self.interface.on_change()
-        self.interface._validate()
+        self.interface._value_changed()
 
     def winforms_key_press(self, sender, event):
         if ord(event.KeyChar) == int(WinForms.Keys.Enter):


Test patch:
diff --git a/core/tests/widgets/test_textinput.py b/core/tests/widgets/test_textinput.py
index 1c0f7271fa..db11f12c2d 100644
--- a/core/tests/widgets/test_textinput.py
+++ b/core/tests/widgets/test_textinput.py
@@ -3,6 +3,7 @@
 import pytest
 
 import toga
+from toga.validators import Number
 from toga_dummy.utils import (
     EventLog,
     assert_action_not_performed,
@@ -113,6 +114,35 @@ def test_value(widget, value, expected, validator):
     on_change_handler.assert_called_once_with(widget)
 
 
+def test_validation_order():
+    """Widget value validation is performed in the correct order."""
+    results = {}
+
+    def on_change(widget):
+        results["valid"] = widget.is_valid
+
+    # Define a validator that only accepts numbers
+    text_input = toga.TextInput(on_change=on_change, validators=[Number()])
+
+    # Widget is initially valid with a number
+    text_input.value = "1234"
+
+    # Change handler was invoked and results are checked
+    assert results["valid"]
+
+    # Widget is invalid with text
+    text_input.value = "hello"
+
+    # Change handler was invoked and results are checked
+    assert not results["valid"]
+
+    # Widget is valid again with a number
+    text_input.value = "1234"
+
+    # Confirm final results are True
+    assert results["valid"]
+
+
 @pytest.mark.parametrize(
     "value, expected",
     [