From 3d09185f3f31afc87845323f1260fdc9cd30dd89 Mon Sep 17 00:00:00 2001 From: Bruno Rino Date: Tue, 10 Oct 2023 19:21:31 +0100 Subject: [PATCH 1/5] Test undo/redo behaviour of text input widgets on macOS. The cocoa MultilineTextInput widget has been updated to enable undo --- android/tests_backend/widgets/base.py | 6 +++ .../toga_cocoa/widgets/multilinetextinput.py | 1 + cocoa/tests_backend/widgets/base.py | 13 +++++++ .../widgets/multilinetextinput.py | 5 ++- cocoa/tests_backend/widgets/numberinput.py | 4 ++ cocoa/tests_backend/widgets/textinput.py | 4 ++ gtk/tests_backend/widgets/base.py | 6 +++ iOS/tests_backend/widgets/base.py | 6 +++ .../tests/widgets/test_multilinetextinput.py | 1 + testbed/tests/widgets/test_numberinput.py | 38 +++++++++++++++++++ testbed/tests/widgets/test_passwordinput.py | 1 + testbed/tests/widgets/test_textinput.py | 32 ++++++++++++++++ winforms/tests_backend/widgets/base.py | 6 +++ 13 files changed, 122 insertions(+), 1 deletion(-) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 8a998f7bf3..a596b0fa51 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -231,6 +231,12 @@ def is_hidden(self): def has_focus(self): return self.widget.app._impl.native.getCurrentFocus() == self.native + async def undo(self): + raise NotImplementedError() + + async def redo(self): + raise NotImplementedError() + def find_view_by_type(root, cls): assert isinstance(root, View) diff --git a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py index bda6c6ab68..b08fa86c1f 100644 --- a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py +++ b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py @@ -41,6 +41,7 @@ def create(self): self.native_text.editable = True self.native_text.selectable = True + self.native_text.allowsUndo = True self.native_text.verticallyResizable = True self.native_text.horizontallyResizable = False self.native_text.usesAdaptiveColorMappingForDarkAppearance = True diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index 5138c22e72..9fcfe7b3c9 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -1,6 +1,7 @@ from rubicon.objc import NSPoint from toga.colors import TRANSPARENT +from toga_cocoa.keys import NSEventModifierFlagCommand, NSEventModifierFlagShift from toga_cocoa.libs import NSEvent, NSEventType from ..fonts import FontMixin @@ -111,6 +112,7 @@ async def type_character(self, char, modifierFlags=0): # Convert the requested character into a Cocoa keycode. # This table is incomplete, but covers all the basics. key_code = { + "": 51, "": 53, " ": 49, "\n": 36, @@ -142,6 +144,9 @@ async def type_character(self, char, modifierFlags=0): "z": 6, }.get(char.lower(), 0) + if modifierFlags: + char = None + # This posts a single keyDown followed by a keyUp, matching "normal" keyboard operation. await self.post_event( NSEvent.keyEventWithType( @@ -194,3 +199,11 @@ async def mouse_event( ), delay=delay, ) + + async def undo(self): + await self.type_character("z", modifierFlags=NSEventModifierFlagCommand) + + async def redo(self): + await self.type_character( + "z", modifierFlags=NSEventModifierFlagCommand | NSEventModifierFlagShift + ) diff --git a/cocoa/tests_backend/widgets/multilinetextinput.py b/cocoa/tests_backend/widgets/multilinetextinput.py index 0b9246d09b..9584f426c6 100644 --- a/cocoa/tests_backend/widgets/multilinetextinput.py +++ b/cocoa/tests_backend/widgets/multilinetextinput.py @@ -1,5 +1,5 @@ from toga.colors import TRANSPARENT -from toga_cocoa.libs import NSScrollView, NSTextView +from toga_cocoa.libs import NSRange, NSScrollView, NSTextView from .base import SimpleProbe from .properties import toga_alignment, toga_color @@ -96,3 +96,6 @@ def vertical_scroll_position(self): async def wait_for_scroll_completion(self): # No animation associated with scroll, so this is a no-op pass + + def set_cursor_at_end(self): + self.native.selectedRange = NSRange(len(self.value), 0) diff --git a/cocoa/tests_backend/widgets/numberinput.py b/cocoa/tests_backend/widgets/numberinput.py index 30394f95c3..3b376cdcf2 100644 --- a/cocoa/tests_backend/widgets/numberinput.py +++ b/cocoa/tests_backend/widgets/numberinput.py @@ -3,6 +3,7 @@ from toga.colors import TRANSPARENT from toga_cocoa.libs import ( NSEventType, + NSRange, NSStepper, NSTextField, NSTextView, @@ -107,3 +108,6 @@ def has_focus(self): return isinstance(self.native.window.firstResponder, NSTextView) and ( self.native_input.window.firstResponder.delegate == self.native_input ) + + def set_cursor_at_end(self): + self.native_input.currentEditor().selectedRange = NSRange(len(self.value), 0) diff --git a/cocoa/tests_backend/widgets/textinput.py b/cocoa/tests_backend/widgets/textinput.py index 1001dab198..bdeee57a29 100644 --- a/cocoa/tests_backend/widgets/textinput.py +++ b/cocoa/tests_backend/widgets/textinput.py @@ -2,6 +2,7 @@ from toga.constants import RIGHT from toga_cocoa.libs import ( NSLeftTextAlignment, + NSRange, NSRightTextAlignment, NSTextField, NSTextView, @@ -82,3 +83,6 @@ def has_focus(self): return isinstance(self.native.window.firstResponder, NSTextView) and ( self.native.window.firstResponder.delegate == self.native ) + + def set_cursor_at_end(self): + self.native.currentEditor().selectedRange = NSRange(len(self.value), 0) diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index a3cd35d5ae..a060967685 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -167,3 +167,9 @@ def event_handled(widget, e): # caused by typing a character doesn't fully propegate. A # short delay fixes this. await asyncio.sleep(0.04) + + async def undo(self): + raise NotImplementedError() + + async def redo(self): + raise NotImplementedError() diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index 75afdb0ad2..b7f43c46d0 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -166,3 +166,9 @@ async def type_character(self, char): self.native.insertText(char) else: self.native.insertText("") + + async def undo(self): + raise NotImplementedError() + + async def redo(self): + raise NotImplementedError() diff --git a/testbed/tests/widgets/test_multilinetextinput.py b/testbed/tests/widgets/test_multilinetextinput.py index ca4ecdf9bc..06ec4c3811 100644 --- a/testbed/tests/widgets/test_multilinetextinput.py +++ b/testbed/tests/widgets/test_multilinetextinput.py @@ -26,6 +26,7 @@ test_on_change_focus, test_on_change_programmatic, test_on_change_user, + test_undo_redo, test_value_not_hidden, ) diff --git a/testbed/tests/widgets/test_numberinput.py b/testbed/tests/widgets/test_numberinput.py index 9873f49d19..2415ff342f 100644 --- a/testbed/tests/widgets/test_numberinput.py +++ b/testbed/tests/widgets/test_numberinput.py @@ -5,6 +5,7 @@ import toga +from ..conftest import skip_on_platforms from .properties import ( # noqa: F401 test_alignment, test_background_color, @@ -226,3 +227,40 @@ async def test_increment_decrement(widget, probe): assert widget.value == expected handler.assert_called_once_with(widget) handler.reset_mock() + + +async def test_undo_redo(widget, probe): + "The widget supports undo and redo." + skip_on_platforms("android", "iOS", "linux", "windows") + + widget.step = "0.00001" + text_0 = "3.14000" + text_1 = "3.14159" + text_extra = "159" + widget.value = text_0 + + widget.focus() + probe.set_cursor_at_end() + + # type more text + for _ in text_extra: + await probe.type_character("") + await probe.redraw(f"Widget value should be {text_0[:-3]!r}") + for char in text_extra: + await probe.type_character(char) + await probe.redraw(f"Widget value should be {text_1!r}") + + assert widget.value == Decimal(text_1) + assert Decimal(probe.value) == Decimal(text_1) + + # undo + await probe.undo() + await probe.redraw(f"Widget value should be {text_0!r}") + assert widget.value == Decimal(text_0) + assert Decimal(probe.value) == Decimal(text_0) + + # redo + await probe.redo() + await probe.redraw(f"Widget value should be {text_1!r}") + assert widget.value == Decimal(text_1) + assert Decimal(probe.value) == Decimal(text_1) diff --git a/testbed/tests/widgets/test_passwordinput.py b/testbed/tests/widgets/test_passwordinput.py index 35ba936707..d5b9acc05e 100644 --- a/testbed/tests/widgets/test_passwordinput.py +++ b/testbed/tests/widgets/test_passwordinput.py @@ -26,6 +26,7 @@ test_on_change_user, test_on_confirm, test_text_value, + test_undo_redo, test_validation, verify_focus_handlers, verify_vertical_alignment, diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 72ea9d7c45..3b5fe88d4a 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -5,6 +5,7 @@ import toga from toga.constants import CENTER +from ..conftest import skip_on_platforms from ..data import TEXTS from .properties import ( # noqa: F401 test_alignment, @@ -213,3 +214,34 @@ async def test_text_value(widget, probe): assert widget.value == str(text).replace("\n", " ") assert probe.value == str(text).replace("\n", " ") + + +async def test_undo_redo(widget, probe): + "The widget supports undo and redo." + skip_on_platforms("android", "iOS", "linux", "windows") + + text_0 = str(widget.value) + text_extra = " World!" + text_1 = text_0 + text_extra + + widget.focus() + probe.set_cursor_at_end() + + # type more text + for char in text_extra: + await probe.type_character(char) + await probe.redraw(f"Widget value should be {text_1!r}") + assert widget.value == text_1 + assert probe.value == text_1 + + # undo + await probe.undo() + await probe.redraw(f"Widget value should be {text_0!r}") + assert widget.value == text_0 + assert probe.value == text_0 + + # redo + await probe.redo() + await probe.redraw(f"Widget value should be {text_1!r}") + assert widget.value == text_1 + assert probe.value == text_1 diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index de44b602ce..374bd910b7 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -142,3 +142,9 @@ def is_hidden(self): @property def has_focus(self): return self.native.ContainsFocus + + async def undo(self): + raise NotImplementedError() + + async def redo(self): + raise NotImplementedError() From 2a4edfbc31eb4e0ac4cd981ab1f3d656b98cec7c Mon Sep 17 00:00:00 2001 From: Bruno Rino Date: Tue, 10 Oct 2023 19:32:36 +0100 Subject: [PATCH 2/5] Added towncrier --- changes/2151.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/2151.feature.rst diff --git a/changes/2151.feature.rst b/changes/2151.feature.rst new file mode 100644 index 0000000000..4bc38f8912 --- /dev/null +++ b/changes/2151.feature.rst @@ -0,0 +1 @@ +The cocoa MultilineTextInput widget has been updated to enable undo From 53e593e01cf0fbb7d8134a18d9eb7ed2ef345dc2 Mon Sep 17 00:00:00 2001 From: bruno-rino Date: Wed, 11 Oct 2023 12:19:51 +0200 Subject: [PATCH 3/5] Update changes/2151.feature.rst Co-authored-by: Russell Keith-Magee --- changes/2151.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/2151.feature.rst b/changes/2151.feature.rst index 4bc38f8912..9ec4d2524c 100644 --- a/changes/2151.feature.rst +++ b/changes/2151.feature.rst @@ -1 +1 @@ -The cocoa MultilineTextInput widget has been updated to enable undo +Text input widgets on macOS now support undo and redo. From 48770036017b626cd3ab1e7f1a1ef84521bb44c4 Mon Sep 17 00:00:00 2001 From: Bruno Rino Date: Wed, 11 Oct 2023 11:23:26 +0100 Subject: [PATCH 4/5] Replace `NotImplementedError` with `pytest.skip` --- android/tests_backend/widgets/base.py | 5 +++-- gtk/tests_backend/widgets/base.py | 6 ++++-- iOS/tests_backend/widgets/base.py | 5 +++-- testbed/tests/widgets/test_textinput.py | 2 -- winforms/tests_backend/widgets/base.py | 5 +++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index a596b0fa51..b59bd8b56a 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -1,5 +1,6 @@ import asyncio +import pytest from java import dynamic_proxy from pytest import approx @@ -232,10 +233,10 @@ def has_focus(self): return self.widget.app._impl.native.getCurrentFocus() == self.native async def undo(self): - raise NotImplementedError() + pytest.skip("Undo not supported on this platform") async def redo(self): - raise NotImplementedError() + pytest.skip("Redo not supported on this platform") def find_view_by_type(root, cls): diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index a060967685..b7573bc83f 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -1,6 +1,8 @@ import asyncio from threading import Event +import pytest + from toga_gtk.libs import Gdk, Gtk from ..fonts import FontMixin @@ -169,7 +171,7 @@ def event_handled(widget, e): await asyncio.sleep(0.04) async def undo(self): - raise NotImplementedError() + pytest.skip("Undo not supported on this platform") async def redo(self): - raise NotImplementedError() + pytest.skip("Redo not supported on this platform") diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index b7f43c46d0..bd5eca3da1 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -1,3 +1,4 @@ +import pytest from rubicon.objc import ObjCClass from toga_iOS.libs import UIApplication @@ -168,7 +169,7 @@ async def type_character(self, char): self.native.insertText("") async def undo(self): - raise NotImplementedError() + pytest.skip("Undo not supported on this platform") async def redo(self): - raise NotImplementedError() + pytest.skip("Redo not supported on this platform") diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 3b5fe88d4a..26d97d151b 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -5,7 +5,6 @@ import toga from toga.constants import CENTER -from ..conftest import skip_on_platforms from ..data import TEXTS from .properties import ( # noqa: F401 test_alignment, @@ -218,7 +217,6 @@ async def test_text_value(widget, probe): async def test_undo_redo(widget, probe): "The widget supports undo and redo." - skip_on_platforms("android", "iOS", "linux", "windows") text_0 = str(widget.value) text_extra = " World!" diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 374bd910b7..6598b68e93 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -1,3 +1,4 @@ +import pytest from pytest import approx from System import EventArgs, Object from System.Drawing import Color, SystemColors @@ -144,7 +145,7 @@ def has_focus(self): return self.native.ContainsFocus async def undo(self): - raise NotImplementedError() + pytest.skip("Undo not supported on this platform") async def redo(self): - raise NotImplementedError() + pytest.skip("Redo not supported on this platform") From f37733281445691736dcebf38de3a4159adec223 Mon Sep 17 00:00:00 2001 From: Bruno Rino Date: Wed, 11 Oct 2023 15:13:52 +0100 Subject: [PATCH 5/5] Implement missing `set_cursor_at_end` method as `pytest.skip` --- android/tests_backend/widgets/numberinput.py | 9 ++++++--- android/tests_backend/widgets/textinput.py | 4 ++++ gtk/tests_backend/widgets/multilinetextinput.py | 5 +++++ gtk/tests_backend/widgets/numberinput.py | 5 +++++ gtk/tests_backend/widgets/textinput.py | 5 +++++ iOS/tests_backend/widgets/numberinput.py | 9 ++++++--- iOS/tests_backend/widgets/textinput.py | 4 ++++ winforms/tests_backend/widgets/numberinput.py | 4 ++++ winforms/tests_backend/widgets/textinput.py | 4 ++++ 9 files changed, 43 insertions(+), 6 deletions(-) diff --git a/android/tests_backend/widgets/numberinput.py b/android/tests_backend/widgets/numberinput.py index 716e1eb56c..018ffe8582 100644 --- a/android/tests_backend/widgets/numberinput.py +++ b/android/tests_backend/widgets/numberinput.py @@ -1,4 +1,4 @@ -from pytest import xfail +import pytest from .textinput import TextInputProbe @@ -16,7 +16,10 @@ def clear_input(self): self.native.setText("") async def increment(self): - xfail("This backend doesn't support stepped increments") + pytest.xfail("This backend doesn't support stepped increments") async def decrement(self): - xfail("This backend doesn't support stepped increments") + pytest.xfail("This backend doesn't support stepped increments") + + def set_cursor_at_end(self): + pytest.skip("Cursor positioning not supported on this platform") diff --git a/android/tests_backend/widgets/textinput.py b/android/tests_backend/widgets/textinput.py index 4b055be733..149765bbea 100644 --- a/android/tests_backend/widgets/textinput.py +++ b/android/tests_backend/widgets/textinput.py @@ -1,3 +1,4 @@ +import pytest from java import jclass from android.os import SystemClock @@ -80,3 +81,6 @@ async def type_character(self, char): 0, # metaState ) ) + + def set_cursor_at_end(self): + pytest.skip("Cursor positioning not supported on this platform") diff --git a/gtk/tests_backend/widgets/multilinetextinput.py b/gtk/tests_backend/widgets/multilinetextinput.py index ffc76b3b9d..95d85ffb67 100644 --- a/gtk/tests_backend/widgets/multilinetextinput.py +++ b/gtk/tests_backend/widgets/multilinetextinput.py @@ -1,3 +1,5 @@ +import pytest + from toga_gtk.libs import Gtk from .base import SimpleProbe @@ -113,3 +115,6 @@ def vertical_scroll_position(self): async def wait_for_scroll_completion(self): pass + + def set_cursor_at_end(self): + pytest.skip("Cursor positioning not supported on this platform") diff --git a/gtk/tests_backend/widgets/numberinput.py b/gtk/tests_backend/widgets/numberinput.py index e713153e48..e8bac75474 100644 --- a/gtk/tests_backend/widgets/numberinput.py +++ b/gtk/tests_backend/widgets/numberinput.py @@ -1,3 +1,5 @@ +import pytest + from toga.constants import JUSTIFY, LEFT from toga_gtk.libs import Gtk @@ -45,3 +47,6 @@ def assert_vertical_alignment(self, expected): @property def readonly(self): return not self.native.get_property("editable") + + def set_cursor_at_end(self): + pytest.skip("Cursor positioning not supported on this platform") diff --git a/gtk/tests_backend/widgets/textinput.py b/gtk/tests_backend/widgets/textinput.py index c08ad0a6d4..c96a1af922 100644 --- a/gtk/tests_backend/widgets/textinput.py +++ b/gtk/tests_backend/widgets/textinput.py @@ -1,3 +1,5 @@ +import pytest + from toga.constants import JUSTIFY, LEFT from toga_gtk.libs import Gtk @@ -47,3 +49,6 @@ def assert_vertical_alignment(self, expected): @property def readonly(self): return not self.native.get_property("editable") + + def set_cursor_at_end(self): + pytest.skip("Cursor positioning not supported on this platform") diff --git a/iOS/tests_backend/widgets/numberinput.py b/iOS/tests_backend/widgets/numberinput.py index b5a1a3bd52..b9e9d402cf 100644 --- a/iOS/tests_backend/widgets/numberinput.py +++ b/iOS/tests_backend/widgets/numberinput.py @@ -1,4 +1,4 @@ -from pytest import xfail +import pytest from rubicon.objc import NSRange from toga_iOS.libs import UITextField @@ -19,10 +19,10 @@ def value(self): return str(self.native.text) async def increment(self): - xfail("iOS doesn't support stepped increments") + pytest.xfail("iOS doesn't support stepped increments") async def decrement(self): - xfail("iOS doesn't support stepped increments") + pytest.xfail("iOS doesn't support stepped increments") @property def color(self): @@ -48,3 +48,6 @@ def _prevalidate_input(self, char): shouldChangeCharactersInRange=NSRange(len(self.native.text), 0), replacementString=char, ) + + def set_cursor_at_end(self): + pytest.skip("Cursor positioning not supported on this platform") diff --git a/iOS/tests_backend/widgets/textinput.py b/iOS/tests_backend/widgets/textinput.py index 54bae26a73..784570df30 100644 --- a/iOS/tests_backend/widgets/textinput.py +++ b/iOS/tests_backend/widgets/textinput.py @@ -1,3 +1,4 @@ +import pytest from rubicon.objc import SEL, send_message from toga_iOS.libs import UITextField @@ -56,3 +57,6 @@ def readonly(self): def type_return(self): # Invoke the return handler explicitly. self.native.textFieldShouldReturn(self.native) + + def set_cursor_at_end(self): + pytest.skip("Cursor positioning not supported on this platform") diff --git a/winforms/tests_backend/widgets/numberinput.py b/winforms/tests_backend/widgets/numberinput.py index a00bc976a4..af8d8fd7d1 100644 --- a/winforms/tests_backend/widgets/numberinput.py +++ b/winforms/tests_backend/widgets/numberinput.py @@ -1,3 +1,4 @@ +import pytest from System.Windows.Forms import NumericUpDown from .base import SimpleProbe @@ -38,3 +39,6 @@ def alignment(self): def assert_vertical_alignment(self, expected): # Vertical alignment isn't configurable in this native widget. pass + + def set_cursor_at_end(self): + pytest.skip("Cursor positioning not supported on this platform") diff --git a/winforms/tests_backend/widgets/textinput.py b/winforms/tests_backend/widgets/textinput.py index dc41271b05..2e6b07776e 100644 --- a/winforms/tests_backend/widgets/textinput.py +++ b/winforms/tests_backend/widgets/textinput.py @@ -2,6 +2,7 @@ from ctypes import c_uint from ctypes.wintypes import HWND, LPARAM +import pytest from System.Windows.Forms import TextBox from .base import SimpleProbe @@ -53,3 +54,6 @@ def alignment(self): def assert_vertical_alignment(self, expected): # Vertical alignment isn't configurable in this native widget. pass + + def set_cursor_at_end(self): + pytest.skip("Cursor positioning not supported on this platform")