diff --git a/android/src/toga_android/widgets/multilinetextinput.py b/android/src/toga_android/widgets/multilinetextinput.py index f60a747f83..d7bba1d7fc 100644 --- a/android/src/toga_android/widgets/multilinetextinput.py +++ b/android/src/toga_android/widgets/multilinetextinput.py @@ -15,7 +15,7 @@ def beforeTextChanged(self, _charSequence, _start, _count, _after): pass def afterTextChanged(self, _editable): - self.interface.on_change(widget=self.interface) + self.interface.on_change(None) def onTextChanged(self, _charSequence, _start, _before, _count): pass diff --git a/android/tests_backend/widgets/multilinetextinput.py b/android/tests_backend/widgets/multilinetextinput.py index 958ae026d8..297ce90e32 100644 --- a/android/tests_backend/widgets/multilinetextinput.py +++ b/android/tests_backend/widgets/multilinetextinput.py @@ -8,15 +8,11 @@ class MultilineTextInputProbe(LabelProbe): @property def value(self): - return self.text - - @property - def placeholder(self): - return str(self.native.getHint()) + return self.native.getHint() if self.placeholder_visible else self.text @property def placeholder_visible(self): - return not self.value + return not self.text @property def placeholder_hides_on_focus(self): diff --git a/cocoa/tests_backend/widgets/multilinetextinput.py b/cocoa/tests_backend/widgets/multilinetextinput.py index b16838e16f..901e56a9d2 100644 --- a/cocoa/tests_backend/widgets/multilinetextinput.py +++ b/cocoa/tests_backend/widgets/multilinetextinput.py @@ -15,11 +15,11 @@ def __init__(self, widget): @property def value(self): - return str(self.native_text.string) - - @property - def placeholder(self): - return str(self.native_text.placeholderString) + return str( + self.native_text.placeholderString + if self.placeholder_visible + else self.native_text.string + ) @property def placeholder_visible(self): diff --git a/examples/colors/colors/app.py b/examples/colors/colors/app.py index 77881b108f..e3ee68e7bc 100644 --- a/examples/colors/colors/app.py +++ b/examples/colors/colors/app.py @@ -35,6 +35,7 @@ def startup(self): label = toga.Label("This is a Label", style=Pack(padding=5)) multiline_text_input = toga.MultilineTextInput( value="This is a Multiline Text Input field!", + placeholder="placeholder", style=Pack(padding=5, flex=1), ) number_input = toga.NumberInput(value=1337, style=Pack(padding=5)) @@ -63,6 +64,7 @@ def startup(self): ) text_input = toga.TextInput( value="This is a Text input field!", + placeholder="placeholder", style=Pack(padding=5), ) diff --git a/examples/multilinetextinput/multilinetextinput/app.py b/examples/multilinetextinput/multilinetextinput/app.py index 35c9e61395..3648f4475f 100644 --- a/examples/multilinetextinput/multilinetextinput/app.py +++ b/examples/multilinetextinput/multilinetextinput/app.py @@ -17,7 +17,7 @@ def add_content_pressed(self, widget, **kwargs): ) def clear_pressed(self, widget, **kwargs): - self.multiline_input.clear() + self.multiline_input.value = "" def scroll_to_top(self, widget): self.multiline_input.scroll_to_top() diff --git a/gtk/tests_backend/widgets/multilinetextinput.py b/gtk/tests_backend/widgets/multilinetextinput.py index c98578d170..4d05268775 100644 --- a/gtk/tests_backend/widgets/multilinetextinput.py +++ b/gtk/tests_backend/widgets/multilinetextinput.py @@ -17,22 +17,13 @@ def __init__(self, widget): @property def value(self): - return self.impl.buffer.get_text( - self.impl.buffer.get_start_iter(), self.impl.buffer.get_end_iter(), True - ) + buffer = self.impl.placeholder if self.placeholder_visible else self.impl.buffer + return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) @property def has_focus(self): return self.native_textview.has_focus() - @property - def placeholder(self): - return self.impl.placeholder.get_text( - self.impl.placeholder.get_start_iter(), - self.impl.placeholder.get_end_iter(), - True, - ) - @property def placeholder_visible(self): return self.native_textview.get_buffer() == self.impl.placeholder diff --git a/iOS/tests_backend/widgets/multilinetextinput.py b/iOS/tests_backend/widgets/multilinetextinput.py index 993cc32b5e..2969855e4c 100644 --- a/iOS/tests_backend/widgets/multilinetextinput.py +++ b/iOS/tests_backend/widgets/multilinetextinput.py @@ -12,11 +12,11 @@ class MultilineTextInputProbe(SimpleProbe): @property def value(self): - return str(self.native.text) - - @property - def placeholder(self): - return str(self.native.placeholder_label.text) + return str( + self.native.placeholder_label.text + if self.placeholder_visible + else self.native.text + ) @property def placeholder_visible(self): diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index 16ddf9188d..0c1142c5f5 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -111,97 +111,133 @@ async def test_text_value(widget, probe): async def test_placeholder(widget, probe): "The placeholder displayed by a widget can be changed" - # Placeholder visibility can be focus dependent, so add another - # widget that can take take focus - other = toga.TextInput() - widget.parent.add(other) - other.focus() # Set a value and a placeholder. widget.value = "Hello" widget.placeholder = "placeholder" await probe.redraw("Widget placeholder should not be visible") + assert widget.value == "Hello" + assert widget.placeholder == "placeholder" + assert probe.value == "Hello" + assert not probe.placeholder_visible + widget.value = "placeholder" + await probe.redraw("Placeholder should not be visible, even if value matches it") + assert widget.value == "placeholder" assert widget.placeholder == "placeholder" - assert probe.placeholder == "placeholder" + assert probe.value == "placeholder" assert not probe.placeholder_visible # Clear value, making placeholder visible widget.value = None await probe.redraw("Widget placeholder should be visible") - + assert widget.value == "" assert widget.placeholder == "placeholder" - assert probe.placeholder == "placeholder" + assert probe.value == "placeholder" assert probe.placeholder_visible # Change placeholder while visible widget.placeholder = "replacement" await probe.redraw("Widget placeholder is now 'replacement'") - + assert widget.value == "" assert widget.placeholder == "replacement" - assert probe.placeholder == "replacement" + assert probe.value == "replacement" assert probe.placeholder_visible + +async def test_placeholder_focus(widget, probe): + "Placeholders interact correctly with focus changes" + + widget.value = "" + widget.placeholder = "replacement" + hides_on_focus = probe.placeholder_hides_on_focus + + # Placeholder visibility can be focus dependent, so add another + # widget that can take take focus + other = toga.TextInput() + widget.parent.add(other) + other.focus() + # Give the widget focus; this may hide the placeholder. widget.focus() await probe.redraw("Widget has focus") + assert widget.value == "" assert widget.placeholder == "replacement" - assert probe.placeholder == "replacement" - if probe.placeholder_hides_on_focus: - assert not probe.placeholder_visible - else: - assert probe.placeholder_visible + assert probe.value == "" if hides_on_focus else "replacement" + assert probe.placeholder_visible == (not hides_on_focus) # Give a different widget focus; this will show the placeholder other.focus() await probe.redraw("Widget has lost focus") + assert widget.value == "" assert widget.placeholder == "replacement" - assert probe.placeholder == "replacement" + assert probe.value == "replacement" assert probe.placeholder_visible # Give the widget focus, again widget.focus() await probe.redraw("Widget has focus; placeholder may not be visible") + assert widget.value == "" assert widget.placeholder == "replacement" - assert probe.placeholder == "replacement" - if probe.placeholder_hides_on_focus: - assert not probe.placeholder_visible - else: - assert probe.placeholder_visible + assert probe.value == "" if hides_on_focus else "replacement" + assert probe.placeholder_visible == (not hides_on_focus) # Change the placeholder text while the widget has focus widget.placeholder = "placeholder" await probe.redraw("Widget placeholder should be 'placeholder'") - + assert widget.value == "" assert widget.placeholder == "placeholder" - assert probe.placeholder == "placeholder" - if probe.placeholder_hides_on_focus: - assert not probe.placeholder_visible - else: - assert probe.placeholder_visible + assert probe.value == "" if hides_on_focus else "placeholder" + assert probe.placeholder_visible == (not hides_on_focus) # Give a different widget focus; this will show the placeholder other.focus() await probe.redraw("Widget has lost focus; placeholder should be visible") + assert widget.value == "" assert widget.placeholder == "placeholder" - assert probe.placeholder == "placeholder" + assert probe.value == "placeholder" assert probe.placeholder_visible # Focus in and out while a value is set. widget.value = "example" widget.focus() - await probe.redraw("Widget has focus; placeholder not visible") + await probe.redraw("Widget has focus; value is set") + assert widget.value == "example" assert widget.placeholder == "placeholder" - assert probe.placeholder == "placeholder" + assert probe.value == "example" assert not probe.placeholder_visible other.focus() - await probe.redraw("Widget has lost focus, placeholder not visible") + await probe.redraw("Widget has lost focus, value is set") + assert widget.value == "example" assert widget.placeholder == "placeholder" - assert probe.placeholder == "placeholder" + assert probe.value == "example" assert not probe.placeholder_visible +async def test_placeholder_color(widget, probe): + "Placeholders interact correctly with custom colors" + widget.value = "Hello" + widget.placeholder = "placeholder" + widget.style.color = RED + await probe.redraw("Value is set, color is red") + assert probe.value == "Hello" + assert not probe.placeholder_visible + assert_color(probe.color, named_color(RED)) + + widget.value = "" + await probe.redraw("Value is empty, placeholder is visible") + assert probe.value == "placeholder" + assert probe.placeholder_visible + # The placeholder color varies from platform to platform, so we don't test that. + + widget.value = "Hello" + await probe.redraw("Value is set, color is still red") + assert probe.value == "Hello" + assert not probe.placeholder_visible + assert_color(probe.color, named_color(RED)) + + async def test_text_width_change(widget, probe): "If the widget text is changed, the width of the widget changes" orig_width = probe.width @@ -315,6 +351,8 @@ async def test_background_color(widget, probe): for color in COLORS: widget.style.background_color = color await probe.redraw("Widget text background color should be %s" % color) + if not getattr(probe, "background_supports_alpha", True): + color.a = 1 assert_color(probe.background_color, color) @@ -337,10 +375,13 @@ async def test_background_color_reset(widget, probe): async def test_background_color_transparent(widget, probe): - "Background transparency is treated as a color reset" + "Background transparency is supported" + original = probe.background_color + supports_alpha = getattr(probe, "background_supports_alpha", True) + widget.style.background_color = TRANSPARENT await probe.redraw("Widget text background color should be TRANSPARENT") - assert_color(probe.background_color, TRANSPARENT) + assert_color(probe.background_color, TRANSPARENT if supports_alpha else original) async def test_alignment(widget, probe): diff --git a/testbed/tests/widgets/test_multilinetextinput.py b/testbed/tests/widgets/test_multilinetextinput.py index 0dbb49974b..f89de18426 100644 --- a/testbed/tests/widgets/test_multilinetextinput.py +++ b/testbed/tests/widgets/test_multilinetextinput.py @@ -5,7 +5,6 @@ import toga from toga.style import Pack -from ..conftest import skip_on_platforms from .properties import ( # noqa: F401 test_alignment, test_background_color, @@ -19,6 +18,8 @@ test_font, test_font_attrs, test_placeholder, + test_placeholder_color, + test_placeholder_focus, test_text_value, test_vertical_alignment_top, ) @@ -26,7 +27,6 @@ @pytest.fixture async def widget(): - skip_on_platforms("windows") return toga.MultilineTextInput(value="Hello", style=Pack(flex=1)) @@ -129,3 +129,4 @@ async def test_on_change_handler(widget, probe): # The number of events equals the number of characters typed. assert handler.mock_calls == [call(widget)] * (count + 2) + assert probe.value == "Hello world"[:count] diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 08d01f09d2..8d61692017 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -1,10 +1,12 @@ from abc import abstractmethod from toga_winforms.colors import native_color -from toga_winforms.libs import Point, Size, SystemColors +from toga_winforms.libs import Color, Point, Size, SystemColors class Widget: + _background_supports_alpha = True + def __init__(self, interface): super().__init__() self.interface = interface @@ -108,10 +110,15 @@ def set_color(self, color): self.native.ForeColor = native_color(color) def set_background_color(self, color): + if not hasattr(self, "_default_background"): + self._default_background = self.native.BackColor if color is None: - self.native.BackColor = SystemColors.Control + self.native.BackColor = self._default_background else: - self.native.BackColor = native_color(color) + win_color = native_color(color) + if (win_color != Color.Empty) and (not self._background_supports_alpha): + win_color = Color.FromArgb(255, win_color.R, win_color.G, win_color.B) + self.native.BackColor = win_color # INTERFACE diff --git a/winforms/src/toga_winforms/widgets/multilinetextinput.py b/winforms/src/toga_winforms/widgets/multilinetextinput.py index c95629eed1..cd5ec640c1 100644 --- a/winforms/src/toga_winforms/widgets/multilinetextinput.py +++ b/winforms/src/toga_winforms/widgets/multilinetextinput.py @@ -1,83 +1,93 @@ from travertino.size import at_least from toga_winforms.colors import native_color -from toga_winforms.libs import SystemColors, WinForms +from toga_winforms.libs import HorizontalTextAlignment, SystemColors, WinForms from .base import Widget class MultilineTextInput(Widget): + # Attempting to set a background color with any alpha value other than 1 raises + # "System.ArgumentException: Control does not support transparent background colors" + _background_supports_alpha = False + def create(self): - # because https://stackoverflow.com/a/612234 + # TextBox doesn't support automatic scroll bar visibility, so we use RichTextBox + # (https://stackoverflow.com/a/612234). self.native = WinForms.RichTextBox() self.native.Multiline = True self.native.TextChanged += self.winforms_text_changed - self.native.Enter += self.winforms_enter - self.native.Leave += self.winforms_leave - self._placeholder = None - self._color = SystemColors.WindowText - def winforms_enter(self, sender, event): - if self._placeholder != "" and self.native.Text == self._placeholder: - self.native.Text = "" - self._update_text_color() + # When moving focus with the tab key, the Enter/Leave event handlers see the + # wrong value of ContainsFocus, so we use GotFocus/LostFocus instead. + def focus_handler(sender, event): + self._update_text() - def winforms_leave(self, sender, event): - self._update_text() + self.native.GotFocus += focus_handler + self.native.LostFocus += focus_handler - def set_font(self, font): - if font: - self.native.Font = font._impl.native + # Dummy values used during initialization + self._value = self._placeholder = "" + self._suppress_on_change = True + self.set_color(None) + + def get_readonly(self): + return self.native.ReadOnly def set_readonly(self, value): - self.native.ReadOnly = self.interface.readonly + self.native.ReadOnly = value + + def get_placeholder(self): + return self._placeholder def set_placeholder(self, value): self._placeholder = value self._update_text() + def get_value(self): + return self._value + def set_value(self, value): - self.native.Text = value + self._value = value + if value: + self._suppress_on_change = False self._update_text() - def get_value(self): - if self._placeholder != "" and self.native.Text == self._placeholder: - return "" - else: - return self.native.Text - def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - def set_on_change(self, handler): - pass - def winforms_text_changed(self, sender, event): - if self.interface.on_change: - self.interface.on_change(self.interface) + if not self._suppress_on_change: + self._value = self.native.Text + self.interface.on_change(None) def _update_text(self): - if self._placeholder != "" and self.native.Text == "": + show_placeholder = (not self.native.ContainsFocus) and (self._value == "") + if show_placeholder: + self._suppress_on_change = True self.native.Text = self._placeholder - self._update_placeholder_color() + self.native.ForeColor = SystemColors.GrayText else: - self._update_text_color() - - def _update_text_color(self): - self.native.ForeColor = self._color - - def _update_placeholder_color(self): - self.native.ForeColor = SystemColors.GrayText + if self.native.Text != self._value: # Avoid moving cursor on focus change + self.native.Text = self._value + self.native.ForeColor = self._color + self._suppress_on_change = False def set_color(self, color): - if color: - self._color = native_color(color) - self._update_text_color() + self._color = ( + SystemColors.WindowText if (color is None) else native_color(color) + ) + self._update_text() - def set_background_color(self, value): - if value: - self.native.BackColor = native_color(value) + def set_alignment(self, value): + original_selection = (self.native.SelectionStart, self.native.SelectionLength) + self.native.SelectAll() + self.native.SelectionAlignment = HorizontalTextAlignment(value) + self.native.SelectionStart, self.native.SelectionLength = original_selection + + def set_font(self, font): + self.native.Font = font._impl.native def scroll_to_bottom(self): self.native.SelectionStart = len(self.native.Text) diff --git a/winforms/tests_backend/widgets/multilinetextinput.py b/winforms/tests_backend/widgets/multilinetextinput.py new file mode 100644 index 0000000000..7d29fe3c21 --- /dev/null +++ b/winforms/tests_backend/widgets/multilinetextinput.py @@ -0,0 +1,46 @@ +import System.Windows.Forms +from System.Drawing import SystemColors + +from toga.style.pack import TOP + +from .base import SimpleProbe +from .properties import toga_xalignment + + +class MultilineTextInputProbe(SimpleProbe): + native_class = System.Windows.Forms.RichTextBox + background_supports_alpha = False + + @property + def value(self): + return self.native.Text + + @property + def placeholder_visible(self): + return self.native.ForeColor == SystemColors.GrayText + + @property + def placeholder_hides_on_focus(self): + return True + + @property + def readonly(self): + return self.native.ReadOnly + + async def wait_for_scroll_completion(self): + pass + + async def type_character(self, char): + self.native.AppendText(char) + + # According to the documentation: "SelectionAlignment returns + # SelectionAlignment.Left when the text selection contains multiple paragraphs with + # mixed alignment." + @property + def alignment(self): + self.native.SelectAll() + return toga_xalignment(self.native.SelectionAlignment) + + @property + def vertical_alignment(self): + return TOP diff --git a/winforms/tests_backend/widgets/properties.py b/winforms/tests_backend/widgets/properties.py index 5caafed306..7eb444c163 100644 --- a/winforms/tests_backend/widgets/properties.py +++ b/winforms/tests_backend/widgets/properties.py @@ -1,4 +1,5 @@ from System.Drawing import Color, ContentAlignment, SystemColors +from System.Windows.Forms import HorizontalAlignment from travertino.fonts import Font from toga.colors import TRANSPARENT, rgba @@ -34,6 +35,10 @@ def toga_xalignment(alignment): ContentAlignment.TopRight: RIGHT, ContentAlignment.MiddleRight: RIGHT, ContentAlignment.BottomRight: RIGHT, + # + HorizontalAlignment.Left: LEFT, + HorizontalAlignment.Center: CENTER, + HorizontalAlignment.Right: RIGHT, }[alignment]