From aad61f94d978644692bd04eddbcb88163684e8f9 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 30 Mar 2023 14:48:45 +0800 Subject: [PATCH 01/24] Update base tests and documentation. --- core/pyproject.toml | 2 +- core/src/toga/widgets/base.py | 120 +- core/src/toga/widgets/box.py | 6 +- core/src/toga/widgets/button.py | 2 +- core/src/toga/widgets/optioncontainer.py | 12 +- core/src/toga/widgets/scrollcontainer.py | 12 +- core/src/toga/widgets/splitcontainer.py | 12 +- core/tests/test_widget_registry.py | 220 ++-- core/tests/widgets/test_base.py | 1331 +++++++++++++++------- core/tests/widgets/test_box.py | 6 +- docs/reference/api/widgets/widget.rst | 3 +- dummy/src/toga_dummy/widgets/base.py | 20 +- 12 files changed, 1143 insertions(+), 603 deletions(-) diff --git a/core/pyproject.toml b/core/pyproject.toml index 03ce4ad2bb..23e7c9ecf1 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -9,7 +9,7 @@ relative_files = true # See notes in the root pyproject.toml file. source = ["src"] -source_pkgs = ["toga"] +source_pkgs = ["toga", "toga_dummy"] [tool.coverage.paths] source = [ diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 401235104d..76cda05718 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -1,4 +1,3 @@ -import warnings from builtins import id as identifier from travertino.node import Node @@ -18,7 +17,7 @@ def __init__(self, *args, **kwargs): def __setitem__(self, key, value): # We do not want to allow setting items directly but to use the "add" # method instead. - raise RuntimeError("WidgetRegistry does not allow using item settings directly") + raise RuntimeError("Widgets cannot be directly added to a registry") def update(self, widgets): for widget in widgets: @@ -28,7 +27,7 @@ def add(self, widget): if widget.id in self: # Prevent from adding the same widget twice # or adding 2 widgets with the same id - raise KeyError(f'There is already a widget with "{widget.id}" id') + raise KeyError(f"There is already a widget with the id {widget.id!r}") super().__setitem__(widget.id, widget) def remove(self, id): @@ -39,39 +38,19 @@ def __iter__(self): class Widget(Node): - """This is the base widget implementation that all widgets in Toga derive - from. - - It defines the interface for core functionality for children, styling, - layout and ownership by specific App and Window. - - Apart from the above, this is an abstract implementation which must - be made concrete by some platform-specific code for the _apply_layout - method. - - Args: - id (str): An identifier for this widget. - enabled (bool): Whether or not interaction with the button is possible, defaults to `True`. - style: An optional style object. - If no style is provided then a new one will be created for the widget. - """ - def __init__( self, id=None, - enabled=True, style=None, - factory=None, # DEPRECATED ! ): - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### + """Create a base Toga widget. + + This is an abstract base class; it cannot be instantiated. + + :param id: The ID for the widget. + :param style: A style object. If no style is provided, a default style + will be applied to the widget. + """ super().__init__( style=style if style else Pack(), applicator=TogaApplicator(self) ) @@ -88,8 +67,7 @@ def __repr__(self): @property def id(self): - """The node identifier. This id can be used to target styling - directives.""" + """The unique identifier for the widget.""" return self._id @property @@ -104,8 +82,9 @@ def tab_index(self, tab_index): def add(self, *children): """Add the provided widgets as children of this widget. - If a node already has a different parent, it will be moved over. This - does nothing if a node already is a child of this node. + If a child widget already has parent, it will be re-parented as a child + of this widget. If the child widget is already a child of this widget, + there is no change. Raises ``ValueError`` if this widget cannot have children. @@ -124,8 +103,7 @@ def add(self, *children): child.app = self.app child.window = self.window - if self._impl: - self._impl.add_child(child._impl) + self._impl.add_child(child._impl) if self.window: self.window.content.refresh() @@ -133,8 +111,9 @@ def add(self, *children): def insert(self, index, child): """Insert a widget as a child of this widget. - If the node already has a parent, ownership of the widget will be - transferred. + If a child widget already has parent, it will be re-parented as a child + of this widget. If the child widget is already a child of this widget, + there is no change. Raises ``ValueError`` if this node cannot have children. @@ -154,8 +133,7 @@ def insert(self, index, child): child.app = self.app child.window = self.window - if self._impl: - self._impl.insert_child(index, child._impl) + self._impl.insert_child(index, child._impl) if self.window: self.window.content.refresh() @@ -163,9 +141,10 @@ def insert(self, index, child): def remove(self, *children): """Remove the provided widgets as children of this node. - This does nothing if a given node is not a child of this node. + Any nominated child widget that is not a child of this widget will + not have any change in parentage. - Raises ``ValueError`` if this node is a leaf, and cannot have children. + Raises ``ValueError`` if this widget cannot have children. :param children: The child nodes to remove. """ @@ -176,8 +155,7 @@ def remove(self, *children): child.app = None child.window = None - if self._impl: - self._impl.remove_child(child._impl) + self._impl.remove_child(child._impl) if self.window: self.window.content.refresh() @@ -196,36 +174,24 @@ def app(self): @app.setter def app(self, app): - # If the widget is already assigned to an app, + # If the widget is already assigned to an app if self._app: - if app is None: - # Deregister the widget. - self._app.widgets.remove(self.id) - elif self._app != app: - # raise an error when we already have an app and attempt to override it - # with a different app - raise ValueError("Widget %s is already associated with an App" % self) - else: + if self._app == app: # If app is the same as the previous app, return return - if self._impl: - self._app = app - self._impl.set_app(app) - for child in self.children: - child.app = app + # Deregister the widget from the old app + self._app.widgets.remove(self.id) + + self._app = app + self._impl.set_app(app) + for child in self.children: + child.app = app if app is not None: # Add this widget to the application widget registry app.widgets.add(self) - # Provide an extension point for widgets with - # more complex widget heirarchies - self._set_app(app) - - def _set_app(self, app): - pass - @property def window(self): """The window to which this widget belongs. @@ -242,24 +208,15 @@ def window(self, window): self.window.widgets.remove(self.id) self._window = window - if self._impl: - self._impl.set_window(window) + self._impl.set_window(window) - if self._children is not None: - for child in self._children: - child.window = window + for child in self.children: + child.window = window if window is not None: # Add this widget to the window's widget registry window.widgets.add(self) - # Provide an extension point for widgets with - # more complex widget heirarchies - self._set_window(window) - - def _set_window(self, window): - pass - @property def enabled(self): """Is the widget currently enabled? i.e., can the user interact with the @@ -275,6 +232,8 @@ def refresh(self): # Refresh the layout if self._root: + # We're not the root of the node heirarchy; + # defer the refresh call to the root node. self._root.refresh() else: self.refresh_sublayouts() @@ -287,6 +246,5 @@ def refresh_sublayouts(self): child.refresh_sublayouts() def focus(self): - """Set this widget to have the current input focus.""" - if self._impl is not None: - self._impl.focus() + """Give this widget to have the input focus.""" + self._impl.focus() diff --git a/core/src/toga/widgets/box.py b/core/src/toga/widgets/box.py index d5c50863a1..a8e9a77606 100644 --- a/core/src/toga/widgets/box.py +++ b/core/src/toga/widgets/box.py @@ -20,13 +20,13 @@ def __init__( """ super().__init__(id=id, style=style) + # Create a platform specific implementation of a Box + self._impl = self.factory.Box(interface=self) + self._children = [] if children: self.add(*children) - # Create a platform specific implementation of a Box - self._impl = self.factory.Box(interface=self) - @Widget.enabled.setter def enabled(self, value): # Box doesn't have a "disabled" state diff --git a/core/src/toga/widgets/button.py b/core/src/toga/widgets/button.py index 5e59b1dcde..e2b9d7c393 100644 --- a/core/src/toga/widgets/button.py +++ b/core/src/toga/widgets/button.py @@ -25,7 +25,7 @@ def __init__( :param enabled: Is the button enabled (i.e., can it be pressed?). Optional; by default, buttons are created in an enabled state. """ - super().__init__(id=id, style=style, enabled=enabled) + super().__init__(id=id, style=style) # Create a platform specific implementation of a Button self._impl = self.factory.Button(interface=self) diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index 48ff3c81e6..c92e807844 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -346,12 +346,20 @@ def current_tab(self, current_tab): current_tab = current_tab.index self._impl.set_current_tab_index(current_tab) - def _set_app(self, app): + @Widget.app.setter + def app(self, app): + # Invoke the superclass property setter + Widget.app.fset(self, app) + # Also assign the app to the content in the container for item in self._content: item._content.app = app - def _set_window(self, window): + @Widget.window.setter + def window(self, window): + # Invoke the superclass property setter + Widget.window.fset(self, window) + # Also assign the window to the content in the container for item in self._content: item._content.window = window diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index 28c071aa97..75196cbcc3 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -53,12 +53,20 @@ def __init__( self.content = content self.on_scroll = on_scroll - def _set_app(self, app): + @Widget.app.setter + def app(self, app): + # Invoke the superclass property setter + Widget.app.fset(self, app) + # Also assign the app to the content in the container if self.content: self.content.app = app - def _set_window(self, window): + @Widget.window.setter + def window(self, window): + # Invoke the superclass property setter + Widget.window.fset(self, window) + # Also assign the window to the content in the container if self._content: self._content.window = window diff --git a/core/src/toga/widgets/splitcontainer.py b/core/src/toga/widgets/splitcontainer.py index 45bd8222d3..1f2aa19809 100644 --- a/core/src/toga/widgets/splitcontainer.py +++ b/core/src/toga/widgets/splitcontainer.py @@ -102,13 +102,21 @@ def content(self, content): self._impl.add_content(position, widget._impl, flex) widget.refresh() - def _set_app(self, app): + @Widget.app.setter + def app(self, app): + # Invoke the superclass property setter + Widget.app.fset(self, app) + # Also assign the app to the content in the container if self.content: for content in self.content: content.app = app - def _set_window(self, window): + @Widget.window.setter + def window(self, window): + # Invoke the superclass property setter + Widget.window.fset(self, window) + # Also assign the window to the content in the container if self._content: for content in self._content: diff --git a/core/tests/test_widget_registry.py b/core/tests/test_widget_registry.py index db79a14760..27def4e162 100644 --- a/core/tests/test_widget_registry.py +++ b/core/tests/test_widget_registry.py @@ -1,92 +1,132 @@ -from unittest.mock import Mock +import pytest +import toga from toga.widgets.base import WidgetRegistry -from toga_dummy.utils import TestCase - - -def widget_mock(id): - widget = Mock() - widget.id = id - widget.__repr__ = Mock(return_value=f"Widget(id={id})") - return widget - - -class TestWidgetsRegistry(TestCase): - def setUp(self): - super().setUp() - self.widget_registry = WidgetRegistry() - - def test_empty_registry(self): - self.assertEqual(len(self.widget_registry), 0) - self.assertEqual(list(self.widget_registry), []) - self.assertEqual(str(self.widget_registry), "{}") - - def test_add_widget(self): - id1 = 1234 - widget = widget_mock(id1) - self.widget_registry.add(widget) - - self.assertEqual(len(self.widget_registry), 1) - self.assertEqual(list(self.widget_registry), [widget]) - self.assertEqual(str(self.widget_registry), "{1234: Widget(id=1234)}") - self.assertEqual(self.widget_registry[id1], widget) - - def test_add_two_widgets(self): - id1, id2 = 1234, 6789 - widget1, widget2 = widget_mock(id1), widget_mock(id2) - self.widget_registry.add(widget1) - self.widget_registry.add(widget2) - - self.assertEqual(len(self.widget_registry), 2) - self.assertEqual(set(self.widget_registry), {widget1, widget2}) - self.assertEqual(self.widget_registry[id1], widget1) - self.assertEqual(self.widget_registry[id2], widget2) - - def test_update_widgets(self): - id1, id2, id3 = 1234, 6789, 9821 - widget1, widget2, widget3 = widget_mock(id1), widget_mock(id2), widget_mock(id3) - self.widget_registry.update({widget1, widget2, widget3}) - - self.assertEqual(len(self.widget_registry), 3) - self.assertEqual(set(self.widget_registry), {widget1, widget2, widget3}) - self.assertEqual(self.widget_registry[id1], widget1) - self.assertEqual(self.widget_registry[id2], widget2) - self.assertEqual(self.widget_registry[id3], widget3) - - def test_remove_widget(self): - id1, id2, id3 = 1234, 6789, 9821 - widget1, widget2, widget3 = widget_mock(id1), widget_mock(id2), widget_mock(id3) - self.widget_registry.update({widget1, widget2, widget3}) - self.widget_registry.remove(id2) - - self.assertEqual(len(self.widget_registry), 2) - self.assertEqual(set(self.widget_registry), {widget1, widget3}) - self.assertEqual(self.widget_registry[id1], widget1) - self.assertEqual(self.widget_registry[id3], widget3) - - def test_add_same_widget_twice(self): - id1 = 1234 - widget = widget_mock(id1) - self.widget_registry.add(widget) - self.assertRaises(KeyError, self.widget_registry.add, widget) - - self.assertEqual(len(self.widget_registry), 1) - self.assertEqual(list(self.widget_registry), [widget]) - self.assertEqual(str(self.widget_registry), "{1234: Widget(id=1234)}") - self.assertEqual(self.widget_registry[id1], widget) - - def test_two_widgets_with_same_name(self): - id1 = 1234 - widget1, widget2 = widget_mock(id1), widget_mock(id1) - self.widget_registry.add(widget1) - self.assertRaises(KeyError, self.widget_registry.add, widget2) - - self.assertEqual(len(self.widget_registry), 1) - self.assertEqual(list(self.widget_registry), [widget1]) - self.assertEqual(str(self.widget_registry), "{1234: Widget(id=1234)}") - self.assertEqual(self.widget_registry[id1], widget1) - - def test_using_setitem_directly(self): - id1 = 1234 - widget = widget_mock(id1) - self.assertRaises(RuntimeError, self.widget_registry.__setitem__, id1, widget) + + +@pytest.fixture +def widget_registry(): + return WidgetRegistry() + + +# Create the simplest possible widget with a concrete implementation +class TestWidget(toga.Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._impl = self.factory.Widget(self) + + def __repr__(self): + return f"Widget(id={self.id!r})" + + +def test_empty_registry(widget_registry): + assert len(widget_registry) == 0 + assert list(widget_registry) == [] + assert str(widget_registry) == "{}" + + +def test_add_widget(widget_registry): + "Widgets can be added to the registry" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget_registry.add(widget1) + + assert len(widget_registry) == 1 + assert list(widget_registry) == [widget1] + assert str(widget_registry) == "{'widget-1': Widget(id='widget-1')}" + assert widget_registry["widget-1"] == widget1 + + # Add a second widget + widget2 = TestWidget(id="widget-2") + widget_registry.add(widget2) + + assert len(widget_registry) == 2 + assert widget_registry["widget-1"] == widget1 + assert widget_registry["widget-2"] == widget2 + + +def test_update_widgets(widget_registry): + "The registry can be bulk updated" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget_registry.add(widget1) + + widget2 = TestWidget(id="widget-2") + widget3 = TestWidget(id="widget-3") + widget4 = TestWidget(id="widget-4") + widget_registry.update({widget2, widget3, widget4}) + + assert len(widget_registry) == 4 + assert widget_registry["widget-1"] == widget1 + assert widget_registry["widget-2"] == widget2 + assert widget_registry["widget-3"] == widget3 + assert widget_registry["widget-4"] == widget4 + + +def test_remove_widget(widget_registry): + "A widget can be removed from the repository" + "Widgets can be added to the registry" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget2 = TestWidget(id="widget-2") + widget_registry.update({widget1, widget2}) + + assert len(widget_registry) == 2 + + widget_registry.remove("widget-2") + + assert widget_registry["widget-1"] == widget1 + assert "widget-2" not in widget_registry + + +def test_add_same_widget_twice(widget_registry): + "A widget cannot be added to the same registry twice" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget_registry.add(widget1) + + assert len(widget_registry) == 1 + + # Add the widget again; this raises an error + with pytest.raises( + KeyError, + match=r"There is already a widget with the id 'widget-1'", + ): + widget_registry.add(widget1) + + # Widget is still there + assert len(widget_registry) == 1 + assert widget_registry["widget-1"] == widget1 + + +def test_add_duplicate_id(widget_registry): + "A widget cannot be added to the same registry twice" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget_registry.add(widget1) + + assert len(widget_registry) == 1 + + new_widget = TestWidget(id="widget-1") + + # Add the widget again; this raises an error + with pytest.raises( + KeyError, + match=r"There is already a widget with the id 'widget-1'", + ): + widget_registry.add(new_widget) + + # Widget is still there + assert len(widget_registry) == 1 + assert widget_registry["widget-1"] == widget1 + + +def test_setitem(widget_registry): + "Widgets cannot be directly assigned to the registry" + widget1 = TestWidget(id="widget-1") + + with pytest.raises( + RuntimeError, + match=r"Widgets cannot be directly added to a registry", + ): + widget_registry["new is"] = widget1 diff --git a/core/tests/widgets/test_base.py b/core/tests/widgets/test_base.py index 5828d874bb..6db6a098c5 100644 --- a/core/tests/widgets/test_base.py +++ b/core/tests/widgets/test_base.py @@ -1,413 +1,942 @@ -from unittest.mock import Mock +from unittest.mock import Mock, call + +import pytest import toga from toga.style import Pack -from toga_dummy.utils import TestCase +from toga_dummy.utils import ( + EventLog, + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, + assert_attribute_not_set, + attribute_value, +) + + +# Create the simplest possible widget with a concrete implementation that will +# allow children +class TestWidget(toga.Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._impl = self.factory.Widget(self) + self._children = [] -# Create the simplest possible widget with a concrete implementation -class Widget(toga.Widget): +# Create the simplest possible widget with a concrete implementation that cannot +# have children. +class TestLeafWidget(toga.Widget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._impl = self.factory.Widget(self) -class WidgetTests(TestCase): - def setUp(self): - super().setUp() - - self.id = "widget_id" - self.style = Pack(padding=666) - - self.widget = Widget( - id=self.id, - style=self.style, - ) - - def test_arguments_were_set_correctly(self): - self.assertEqual(self.widget.id, self.id) - self.assertEqual(self.widget.style.padding, self.style.padding) - - def test_create_widget_with_no_style(self): - widget = toga.Widget() - self.assertTrue(isinstance(widget.style, Pack)) - - def test_enabled_with_None(self): - # Using a Button for test because we need a concrete implementation to use this property. - widget = toga.Button("Hello") - widget.enabled = None - self.assertFalse(widget.enabled) - self.assertValueSet(widget, "enabled", False) - - def test_adding_child(self): - self.assertIsNone(self.widget.app) - self.assertIsNone(self.widget.window) - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - # Create a child widget to add to widget. - child = toga.Widget() - - with self.assertRaises(ValueError, msg="Widget cannot have children."): - self.widget.add(child) - - # Deliberately set widget._children = [] to allow it to have children. - # Only for test purposes! - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - - def test_adding_child_with_app(self): - app = toga.App("Test App", "org.beeware.test") - self.widget.app = app - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - - # Create a child widget to add to widget. - child_id = "child-id" - child = Widget(id=child_id) - - # Deliberately set widget._children = [] to allow it to have children. - # Only for test purposes! - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(self.widget.app, app) - self.assertEqual(child.app, app) - self.assertEqual(len(app.widgets), 2) - self.assertEqual(app.widgets[self.id], self.widget) - self.assertEqual(app.widgets[child_id], child) - - def test_adding_child_with_window(self): - window = toga.Window() - window.content = Mock() - self.widget.window = window - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - - # Create a child widget to add to widget. - child_id = "child-id" - child = Widget(id=child_id) - - # Deliberately set widget._children = [] to allow it to have children. - # Only for test purposes! - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(self.widget.window, window) - self.assertEqual(child.window, window) - self.assertEqual(len(window.widgets), 2) - self.assertEqual(window.widgets[self.id], self.widget) - self.assertEqual(window.widgets[child_id], child) - - def test_adding_children(self): - self.assertEqual( - self.widget.children, [], "No children added, should return an empty list." - ) - # Create 2 children to add to widget. - child1 = toga.Widget() - child2 = toga.Widget() - - self.widget._children = [] - self.widget.add(child1, child2) - self.assertEqual(self.widget.children, [child1, child2]) - - def test_adding_child_with_existing_parent(self): - # Create a second parent widget. - widget2 = toga.Widget() - # Create a child widget to add to widget2 before adding to widget. - child = toga.Widget() - - widget2._children = [] - widget2.add(child) - self.assertEqual(widget2.children, [child]) - - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(widget2.children, []) - - def test_inserting_child_into_empty_list(self): - self.assertEqual( - self.widget.children, - [], - "No child was inserted, should return an empty list.", - ) - # Create a child widget to insert into widget. - child = toga.Widget() - - with self.assertRaises(ValueError, msg="Widget cannot have children."): - self.widget.insert(0, child) - - self.widget._children = [] - self.widget.insert(0, child) - self.assertEqual(self.widget.children, [child]) - - def test_inserting_child_into_list_containing_one_child(self): - self.assertEqual( - self.widget.children, - [], - "No child was inserted, should return an empty list.", - ) - # Create 2 children to insert into widget. - child1 = toga.Widget() - child2 = toga.Widget() - - self.widget._children = [] - self.widget.insert(0, child1) - self.widget.insert(1, child2) - self.assertEqual(self.widget.children, [child1, child2]) - - def test_inserting_child_into_list_containing_three_children(self): - self.assertEqual( - self.widget.children, - [], - "No child was inserted, should return an empty list.", - ) - # Create 3 children to add to widget. - child1 = toga.Widget() - child2 = toga.Widget() - child3 = toga.Widget() - # Create a child to insert into widget. - child4 = toga.Widget() - - self.widget._children = [] - self.widget.add(child1, child2, child3) - self.widget.insert(2, child4) - - self.assertEqual(self.widget.children, [child1, child2, child4, child3]) - - def test_inserting_child_with_existing_parent(self): - # Create a second parent widget. - widget2 = toga.Widget() - # Create a child widget to insert into widget2 before inserting into widget. - child = toga.Widget() - - widget2._children = [] - widget2.insert(0, child) - self.assertEqual(widget2.children, [child]) - - self.widget._children = [] - self.widget.insert(0, child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(widget2.children, []) - - def test_removing_child(self): - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - # Create a child widget to add then remove from widget. - child = toga.Widget() - - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - - self.widget.remove(child) - self.assertEqual(self.widget.children, []) - - def test_removing_child_with_app(self): - app = toga.App("Test App", "org.beeware.test") - self.widget.app = app - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - # Create a child widget to add then remove from widget. - child = Widget() - - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(len(app.widgets), 2) - - self.widget.remove(child) - self.assertEqual(self.widget.children, []) - self.assertEqual(len(app.widgets), 1) - self.assertEqual(app.widgets[self.id], self.widget) - - def test_removing_child_with_window(self): - window = toga.Window() - window.content = Mock() - self.widget.window = window - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - # Create a child widget to add then remove from widget. - child = Widget() - - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(len(window.widgets), 2) - - self.widget.remove(child) - self.assertEqual(self.widget.children, []) - self.assertEqual(len(window.widgets), 1) - self.assertEqual(window.widgets[self.id], self.widget) - - def test_removing_children(self): - self.assertEqual( - self.widget.children, [], "No children added, should return an empty list." - ) - # Create 2 children to add then remove from widget. - child1 = toga.Widget() - child2 = toga.Widget() - - self.widget._children = [] - self.widget.add(child1, child2) - self.assertEqual(self.widget.children, [child1, child2]) - - self.widget.remove(child1, child2) - self.assertEqual(self.widget.children, []) - - def test_removing_two_out_of_three_children(self): - self.assertEqual( - self.widget.children, [], "No children added, should return am empty list." - ) - # Create 3 children to add to widget, 2 of which will be removed. - child1 = toga.Widget() - child2 = toga.Widget() - child3 = toga.Widget() - - self.widget._children = [] - self.widget.add(child1, child2, child3) - self.assertEqual(self.widget.children, [child1, child2, child3]) - - self.widget.remove(child1, child3) - self.assertEqual(self.widget.children, [child2]) - - def test_set_app(self): - "A widget can be assigned to an app" - app = toga.App("Test App", "org.beeware.test") - self.assertEqual(len(app.widgets), 0) - self.widget.app = app - - # The app has been assigned - self.assertEqual(self.widget.app, app) - self.assertEqual(len(app.widgets), 1) - self.assertEqual(app.widgets[self.id], self.widget) - - # The app has been assigned to the underlying impl - self.assertValueSet(self.widget, "app", app) - - def test_set_app_with_children(self): - "A widget with children can be assigned to an app" - # Create 2 children to add to widget, 2 of which will be removed. - child_id1, child_id2 = "child-id1", "child-id2" - child1 = Widget(id=child_id1) - child2 = Widget(id=child_id2) - - self.widget._children = [] - self.widget.add(child1, child2) - - app = toga.App("Test App", "org.beeware.test") - self.assertEqual(len(app.widgets), 0) - - self.widget.app = app - - # The app has been assigned to all children - self.assertEqual(self.widget.app, app) - self.assertEqual(child1.app, app) - self.assertEqual(child2.app, app) - self.assertEqual(len(app.widgets), 3) - self.assertEqual(app.widgets[self.id], self.widget) - self.assertEqual(app.widgets[child_id1], child1) - self.assertEqual(app.widgets[child_id2], child2) - - # The app has been assigned to the underlying impls - self.assertValueSet(self.widget, "app", app) - self.assertValueSet(child1, "app", app) - self.assertValueSet(child2, "app", app) - - def test_repeat_set_app(self): - "If a widget is already assigned to an app, doing so again raises an error" - app = toga.App("Test App", "org.beeware.test") - self.widget.app = app - - # The app has been assigned - self.assertEqual(self.widget.app, app) - - # Assign the widget to the same app - app2 = toga.App("Test App", "org.beeware.test") - - with self.assertRaises(ValueError, msg="is already associated with an App"): - self.widget.app = app2 - - # The app is still assigned to the original app - self.assertEqual(self.widget.app, app) - self.assertEqual(list(app.widgets), [self.widget]) - - def test_repeat_same_set_app(self): - "If a widget is already assigned to an app, re-assigning to the same app is OK" - app = toga.App("Test App", "org.beeware.test") - self.widget.app = app - - # The app has been assigned - self.assertEqual(self.widget.app, app) - - # Assign the widget to the same app - self.widget.app = app - - # The app is still assigned - self.assertEqual(self.widget.app, app) - self.assertEqual(list(app.widgets), [self.widget]) - - def test_remove_app(self): - "A widget can be assigned to an app" - app = toga.App("Test App", "org.beeware.test") - self.assertEqual(len(app.widgets), 0) - - self.widget.app = app - self.widget.app = None - - # The app has been unassigned - self.assertIsNone(self.widget.app) - self.assertEqual(len(app.widgets), 0) - - # The app has been assigned to the underlying impl - self.assertValueSet(self.widget, "app", None) - - def test_set_window(self): - window = toga.Window() - self.assertEqual(len(window.widgets), 0) - self.widget.window = window - - self.assertEqual(len(window.widgets), 1) - self.assertEqual(window.widgets[self.id], self.widget) - - def test_replace_window(self): - window1, window2 = (toga.Window(), toga.Window()) - self.widget.window = window1 - self.assertEqual(len(window1.widgets), 1) - self.assertEqual(len(window2.widgets), 0) - - self.widget.window = window2 - self.assertEqual(len(window1.widgets), 0) - self.assertEqual(len(window2.widgets), 1) - self.assertEqual(window2.widgets[self.id], self.widget) - - def test_remove_window(self): - window = toga.Window() - self.assertEqual(len(window.widgets), 0) - - self.widget.window = window - self.widget.window = None - - self.assertEqual(len(window.widgets), 0) +@pytest.fixture +def widget(): + return TestWidget(id="widget_id", style=Pack(padding=666)) + + +def test_simple_widget(): + """A simple widget can be created""" + widget = TestWidget() + + # Round trip the impl/interface + assert widget._impl.interface == widget + assert_action_performed(widget, "create Widget") + + # Base properties of the widget have been set + assert widget.id == str(id(widget)) + assert isinstance(widget.style, Pack) + assert widget.style.padding == (0, 0, 0, 0) + + +def test_widget_created(widget): + """A widget can be created with arguments.""" + # Round trip the impl/interface + assert widget._impl.interface == widget + assert_action_performed(widget, "create Widget") + + # Base properties of the widget have been set + assert widget.enabled + assert widget.id == "widget_id" + assert isinstance(widget.style, Pack) + assert widget.style.padding == (666, 666, 666, 666) + + +def test_add_child_to_leaf(): + "A child cannot be added to a leaf node" + leaf = TestLeafWidget() + + # Widget doesn't have an app or window + assert leaf.app is None + assert leaf.window is None + + # Leaf nodes report an empty child list + assert leaf.children == [] + + # Create a child widget + child = TestLeafWidget() + + # Add the child. + with pytest.raises(ValueError, match=r"Cannot add children"): + leaf.add(child) + + +def test_add_child_without_app(widget): + "A child can be added to a node when there's no underlying app" + # Widget doesn't have an app or window + assert widget.app is None + assert widget.window is None + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget() + + # Add the child. + widget.add(child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app is None + assert child.window is None + + # The impl's add_child has been invoked + assert_action_performed_with(widget, "add child", child=child) + + +def test_add_child(widget): + "A child can be added to a node when there's an app & window" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget(id="child_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child_id" not in app.widgets + + # Add the child. + widget.add(child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app == app + assert child.window == window + + # The impl's add_child has been invoked + assert_action_performed_with(widget, "add child", child=child) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + # App's widget index has been updated + assert len(app.widgets) == 2 + assert app.widgets["widget_id"] == widget + assert app.widgets["child_id"] == child + + +def test_add_multiple_children(widget): + "Multiple children can be added in one call" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create 3 child widgets + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child1_id" not in app.widgets + assert "child2_id" not in app.widgets + assert "child3_id" not in app.widgets + + # Add the children. + widget.add(child1, child2, child3) + + # Widget knows about the child and vice versa + assert widget.children == [child1, child2, child3] + assert child1.parent == widget + assert child2.parent == widget + assert child3.parent == widget + + # Children has inherited parent's app/window details + assert child1.app == app + assert child1.window == window + + assert child2.app == app + assert child2.window == window + + assert child3.app == app + assert child3.window == window + + # The impl's add_child has been invoked 3 time + assert_action_performed_with(widget, "add child", child=child1) + assert_action_performed_with(widget, "add child", child=child2) + assert_action_performed_with(widget, "add child", child=child3) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + # App's widget index has been updated + assert len(app.widgets) == 4 + assert app.widgets["widget_id"] == widget + assert app.widgets["child1_id"] == child1 + assert app.widgets["child2_id"] == child2 + assert app.widgets["child3_id"] == child3 + + +def test_reparent_child(widget): + "A widget can be reparented" + # Create a second parent widget, and add a child to it + other = TestWidget(id="other") + child = TestLeafWidget(id="child_id") + other.add(child) + + assert other.children == [child] + assert child.parent == other + + # Add the child to the widget + widget.add(child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Original parent as lost the child + assert other.children == [] + + # The impl's add_child has been invoked + assert_action_performed_with(widget, "add child", child=child) + + +def test_reparent_child_to_self(widget): + "Reparenting a widget to the same parent is a no-op" + # Add a child to the widget + child = TestLeafWidget(id="child_id") + widget.add(child) + + assert widget.children == [child] + assert child.parent == widget + + # Reset the event log so all previous add events are lost + EventLog.reset() + + # Add the child to the widget again + widget.add(child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # The impl's add_child has *not* been invoked, + # as the widget was already a child + assert_action_not_performed(widget, "add child") + + +def test_insert_child_into_leaf(): + "A child cannot be inserted into a leaf node" + leaf = TestLeafWidget() + + # Widget doesn't have an app or window + assert leaf.app is None + assert leaf.window is None + + # Leaf nodes report an empty child list + assert leaf.children == [] + + # Create a child widget + child = TestLeafWidget() + + # insert the child. + with pytest.raises(ValueError, match=r"Cannot insert child"): + leaf.insert(0, child) + + +def test_insert_child_without_app(widget): + "A child can be inserted into a node when there's no underlying app" + # Widget doesn't have an app or window + assert widget.app is None + assert widget.window is None + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget() + + # insert the child. + widget.insert(0, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app is None + assert child.window is None + + # The impl's insert_child has been invoked + assert_action_performed_with(widget, "insert child", child=child) + + +def test_insert_child(widget): + "A child can be inserted into a node when there's an app & window" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget(id="child_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child_id" not in app.widgets + + # insert the child. + widget.insert(0, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app == app + assert child.window == window + + # The impl's insert_child has been invoked + assert_action_performed_with(widget, "insert child", child=child) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + # App's widget index has been updated + assert len(app.widgets) == 2 + assert app.widgets["widget_id"] == widget + assert app.widgets["child_id"] == child + + +def test_insert_position(widget): + "Insert can put a child into a specific position" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create 3 child widgets + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child1_id" not in app.widgets + assert "child2_id" not in app.widgets + assert "child3_id" not in app.widgets + + # insert the children. + widget.insert(0, child1) + widget.insert(0, child2) + widget.insert(1, child3) + + # Widget knows about the child and vice versa + assert widget.children == [child2, child3, child1] + assert child1.parent == widget + assert child2.parent == widget + assert child3.parent == widget + + # Children has inherited parent's app/window details + assert child1.app == app + assert child1.window == window + + assert child2.app == app + assert child2.window == window + + assert child3.app == app + assert child3.window == window + + # The impl's insert_child has been invoked 3 time + assert_action_performed_with(widget, "insert child", child=child1) + assert_action_performed_with(widget, "insert child", child=child2) + assert_action_performed_with(widget, "insert child", child=child3) + + # The window layout has been refreshed on each insertion + assert window.content.refresh.mock_calls == [call()] * 3 + + # App's widget index has been updated + assert len(app.widgets) == 4 + assert app.widgets["widget_id"] == widget + assert app.widgets["child1_id"] == child1 + assert app.widgets["child2_id"] == child2 + assert app.widgets["child3_id"] == child3 + + +def test_insert_bad_position(widget): + "If the position is invalid, an error is raised" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget(id="child_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child_id" not in app.widgets + + # Insert the child at an position greater than the length of the list. + # Widget will be added to the end of the list. + widget.insert(37, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app == app + assert child.window == window + + # The impl's insert_child has been invoked + assert_action_performed_with(widget, "insert child", child=child) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + # App's widget index has been updated + assert len(app.widgets) == 2 + assert app.widgets["widget_id"] == widget + assert app.widgets["child_id"] == child + + +def test_insert_reparent_child(widget): + "A widget can be reparented by insertion" + # Create a second parent widget, and add a child to it + other = TestWidget(id="other") + child = TestLeafWidget(id="child_id") + other.add(child) + + assert other.children == [child] + assert child.parent == other + + # insert the child to the widget + widget.insert(0, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Original parent as lost the child + assert other.children == [] + + # The impl's insert_child has been invoked + assert_action_performed_with(widget, "insert child", child=child) + + +def test_insert_reparent_child_to_self(widget): + "Reparenting a widget to the same parent by insertion is a no-op" + # Add a child to the widget + child = TestLeafWidget(id="child_id") + widget.add(child) + + assert widget.children == [child] + assert child.parent == widget + + # Reset the event log so all previous insert events are lost + EventLog.reset() + + # insert the child to the widget again + widget.insert(0, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # The impl's insert_child has *not* been invoked, + # as the widget was already a child + assert_action_not_performed(widget, "insert child") + + +def test_remove_child_without_app(widget): + "A child without an app or window can be removed from a widget" + # Add a child to the widget + child = TestLeafWidget(id="child_id") + widget.add(child) + + assert widget.children == [child] + assert child.parent == widget + assert child.app is None + assert child.window is None + + # Remove the child + widget.remove(child) + + # Parent doesn't know about the child, and vice versa + assert widget.children == [] + assert child.parent is None + + # App and window are still None + assert child.app is None + assert child.window is None + + # The impl's remove_child has been invoked + assert_action_performed_with(widget, "remove child", child=child) + + +def test_remove_child(widget): + "A child associated with an app & window can be removed from a widget" + # Add a child to the widget + child = TestLeafWidget(id="child_id") + widget.add(child) + + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + assert widget.children == [child] + assert child.parent == widget + assert child.app == app + assert child.window == window + + # Remove the child + widget.remove(child) + + # Parent doesn't know about the child, and vice versa + assert widget.children == [] + assert child.parent is None + + # app and window have been reset. + assert child.app is None + assert child.window is None + + # The impl's remove_child has been invoked + assert_action_performed_with(widget, "remove child", child=child) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + +def test_remove_multiple_children(widget): + "Multiple children can be removed from a widget" + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + assert widget.children == [child1, child2, child3] + for child in widget.children: + assert child.parent == widget + assert child.app == app + assert child.window == window + + # Remove 2 children + widget.remove(child1, child3) + + # Parent doesn't know about the removed children, and vice versa + assert widget.children == [child2] + assert child1.parent is None + assert child2.parent == widget + assert child3.parent is None + + # App and window have been reset on the removed widgets + assert child1.app is None + assert child1.window is None + + assert child2.app == app + assert child2.window == window + + assert child3.app is None + assert child3.window is None + + # The impl's remove_child has been invoked twice + assert_action_performed_with(widget, "remove child", child=child1) + assert_action_performed_with(widget, "remove child", child=child3) + + # The window layout has been refreshed once + window.content.refresh.assert_called_once_with() + + +def test_remove_from_non_parent(widget): + "Trying to remove a child from a widget other than it's parent is a no-op" + # Create a second parent widget, and add a child to it + other = TestWidget(id="other") + child = TestLeafWidget(id="child_id") + other.add(child) + + assert widget.children == [] + assert other.children == [child] + assert child.parent == other + + # Remove the child from *widget*, which is not the parent + widget.remove(child) + + # Nothing has changed. + assert widget.children == [] + assert other.children == [child] + assert child.parent == other + + # The impl's remove_child has been invoked + assert_action_not_performed(widget, "remove child") + + +def test_set_app(widget): + "A widget can be assigned to an app" + app = toga.App("Test App", "org.beeware.test") + assert len(app.widgets) == 0 + + # Assign the widget to an app + widget.app = app + + # The app has been assigned + assert widget.app == app + + # The widget index has been updated + assert len(app.widgets) == 1 + assert app.widgets["widget_id"] == widget + + # The impl has had it's app property set. + assert attribute_value(widget, "app") == app + + +def test_set_app_with_children(widget): + "If a widget has children, the children get the app assignment" + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + # Set up an app + app = toga.App("Test App", "org.beeware.test") + assert len(app.widgets) == 0 + + # Assign the widget to an app + widget.app = app + + # The app has been assigned + assert widget.app == app + + # The children also have the app assigned + assert child1.app == app + assert child2.app == app + assert child3.app == app + + # The widget index has been updated + assert len(app.widgets) == 4 + assert app.widgets["widget_id"] == widget + assert app.widgets["child1_id"] == child1 + assert app.widgets["child2_id"] == child2 + assert app.widgets["child3_id"] == child3 + + # The impl of widget and children have had their app property set. + assert attribute_value(widget, "app") == app + assert attribute_value(child1, "app") == app + assert attribute_value(child2, "app") == app + assert attribute_value(child3, "app") == app + + +def test_set_same_app(widget): + "A widget can be re-assigned to the same app" + app = toga.App("Test App", "org.beeware.test") + assert len(app.widgets) == 0 + + # Assign the widget to an app + widget.app = app + + # Reset the event log so we know the new events + EventLog.reset() + + # Assign the widget to the same app + widget.app = app + + # The impl has not had it's app property set as a result of the update + assert_attribute_not_set(widget, "app") + + +def test_reset_app(widget): + "A widget can be re-assigned to no app" + app = toga.App("Test App", "org.beeware.test") + assert len(app.widgets) == 0 + + # Assign the widget to an app + widget.app = app + + # Reset the event log so we know the new events + EventLog.reset() + + # Clear the app assignment + widget.app = None + + # The app has been assigned + assert widget.app is None + + # The widget index has been updated + assert len(app.widgets) == 0 + + # The impl has had it's app property set. + assert attribute_value(widget, "app") is None + + +def test_set_new_app(widget): + "A widget can be assigned to a different app" + app = toga.App("Test App", "org.beeware.test") + + # Assign the widget to an app + widget.app = app + assert len(app.widgets) == 1 + + # Reset the event log so we know the new events + EventLog.reset() + + # Create a new app + new_app = toga.App("Test App", "org.beeware.test") + assert len(new_app.widgets) == 0 + + # Assign the widget to the same app + widget.app = new_app + + # The widget has been assigned to the new app + assert widget.app == new_app + + # The widget indices has been updated + assert len(app.widgets) == 0 + assert len(new_app.widgets) == 1 + assert new_app.widgets["widget_id"] == widget + + # The impl has had it's app property set. + assert attribute_value(widget, "app") == new_app + + +def test_set_window(widget): + "A widget can be assigned to a window." + window = toga.Window() + assert len(window.widgets) == 0 + assert widget.window is None + + # Assign the widget to a window + widget.window = window + + # Window has been assigned + assert widget.window == window + + # Window Widget registry has been updated + assert len(window.widgets) == 1 + assert window.widgets["widget_id"] == widget + + +def test_set_window_with_children(widget): + "A widget can be assigned to a window." + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + window = toga.Window() + assert len(window.widgets) == 0 + assert widget.window is None + assert child1.window is None + assert child2.window is None + assert child3.window is None + + # Assign the widget to a window + widget.window = window + + # Window has been assigned + assert widget.window == window + assert child1.window == window + assert child2.window == window + assert child3.window == window + + # Window Widget registry has been updated + assert len(window.widgets) == 4 + assert window.widgets["widget_id"] == widget + assert window.widgets["child1_id"] == child1 + assert window.widgets["child2_id"] == child2 + assert window.widgets["child3_id"] == child3 + + +def test_reset_window(widget): + "A widget can be assigned to a different window." + window = toga.Window() + assert len(window.widgets) == 0 + assert widget.window is None + + # Assign the widget to a window + widget.window = window + assert len(window.widgets) == 1 + + # Create a new window + new_window = toga.Window() + + # Assign the widget to the new window + widget.window = new_window + + # Window has been assigned + assert widget.window == new_window + + # Window Widget registry has been updated + assert len(window.widgets) == 0 + assert len(new_window.widgets) == 1 + assert new_window.widgets["widget_id"] == widget + + +def test_unset_window(widget): + "A widget can be assigned to no window." + window = toga.Window() + assert len(window.widgets) == 0 + assert widget.window is None + + # Assign the widget to a window + widget.window = window + assert len(window.widgets) == 1 + + # Assign the widget to no window + widget.window = None + + # The widget doesn't have a window + assert widget.window is None + + # Window Widget registry has been updated + assert len(window.widgets) == 0 + + +@pytest.mark.parametrize( + "value, expected", + [ + (None, False), + ("", False), + ("true", True), + ("false", True), # Evaluated as a string, this value is true. + (0, False), + (1234, True), + ], +) +def test_enabled(widget, value, expected): + "The enabled status of the widget can be changed." + # Widget is initially enabled by default. + assert widget.enabled + + # Set the enabled status + widget.enabled = value + assert widget.enabled == expected + + # Disable the widget + widget.enabled = False + assert not widget.enabled + + # Set the enabled status again + widget.enabled = value + assert widget.enabled == expected + + +def test_refresh_root(widget): + "Refresh can be invoked on the root node" + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + # Refresh the root node + widget.refresh() + + # Root widget was refreshed + assert_action_performed(widget, "refresh") + + +def test_refresh_child(widget): + "Refresh can be invoked on child" + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + # Refresh a child + child2.refresh() + + # Child widget was refreshed + assert_action_performed(child2, "refresh") + + # Root widget was refreshed + assert_action_performed(widget, "refresh") + + +def test_focus(widget): + "A widget can be given focus" + widget.focus() + assert_action_performed(widget, "focus") + + +def test_tab_index(widget): + "The tab index of a widget can be set and retrieved" + # The initial tab index is None + assert widget.tab_index is None - def test_focus(self): - self.widget.focus() - self.assertActionPerformed(self.widget, "focus") + tab_index = 8 + widget.tab_index = tab_index - def test_set_tab_index(self): - tab_index = 8 - self.widget.tab_index = tab_index - self.assertValueSet(self.widget, "tab_index", tab_index) - - def test_get_tab_index(self): - tab_index = 8 - self.widget.tab_index = tab_index - self.assertEqual(self.widget.tab_index, tab_index) - self.assertValueGet(self.widget, "tab_index") + assert widget.tab_index == 8 + assert attribute_value(widget, "tab_index") == tab_index diff --git a/core/tests/widgets/test_box.py b/core/tests/widgets/test_box.py index 36b948cc94..9ce659aa1c 100644 --- a/core/tests/widgets/test_box.py +++ b/core/tests/widgets/test_box.py @@ -2,6 +2,7 @@ from toga_dummy.utils import ( assert_action_not_performed, assert_action_performed, + assert_action_performed_with, ) @@ -25,9 +26,8 @@ def test_create_box_with_children(): assert box._impl.interface == box assert_action_performed(box, "create Box") - # The impl-level add-child will not be called, - # because the box hasn't been assigned to a window - assert_action_not_performed(box, "add child") + assert_action_performed_with(box, "add child", child=child1) + assert_action_performed_with(box, "add child", child=child2) # But the box will have children. assert box.children == [child1, child2] diff --git a/docs/reference/api/widgets/widget.rst b/docs/reference/api/widgets/widget.rst index 6dca67fcc3..6c5b4ec889 100644 --- a/docs/reference/api/widgets/widget.rst +++ b/docs/reference/api/widgets/widget.rst @@ -1,6 +1,8 @@ Widget ====== +The abstract base class of all widgets. This class should not be be instantiated directly. + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,7 +10,6 @@ Widget :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!^(Widget|Component)$)'} -The base class of all widgets. This class should not be be instantiated directly. Reference --------- diff --git a/dummy/src/toga_dummy/widgets/base.py b/dummy/src/toga_dummy/widgets/base.py index 6e57263cb6..d21a3ab3f2 100644 --- a/dummy/src/toga_dummy/widgets/base.py +++ b/dummy/src/toga_dummy/widgets/base.py @@ -1,4 +1,4 @@ -from ..utils import LoggedObject, not_required_on +from ..utils import LoggedObject class Widget(LoggedObject): @@ -10,7 +10,7 @@ def __init__(self, interface): self.create() def create(self): - pass + self._action("create Widget") def set_app(self, app): self._set_value("app", app) @@ -18,14 +18,6 @@ def set_app(self, app): def set_window(self, window): self._set_value("window", window) - @property - def container(self): - return self._get_value("container") - - @container.setter - def container(self, container): - self._set_value("container", container) - def get_enabled(self): return self._get_value("enabled", True) @@ -36,10 +28,10 @@ def focus(self): self._action("focus") def get_tab_index(self): - return self._get_value("tab_index") + return self._get_value("tab_index", None) def set_tab_index(self, tab_index): - return self._set_value("tab_index", tab_index) + self._set_value("tab_index", tab_index) ###################################################################### # APPLICATOR @@ -70,9 +62,5 @@ def insert_child(self, index, child): def remove_child(self, child): self._action("remove child", child=child) - @not_required_on("gtk", "winforms", "android", "web") - def add_constraints(self): - self._action("add constraints") - def refresh(self): self._action("refresh") From b44f27db6c1750ae50cfdfee6e9d0ad38a0090e0 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 3 Apr 2023 09:09:03 +0800 Subject: [PATCH 02/24] Promote set_alignment to a base method, and remove and edge case of nodes with no applicator. --- core/pyproject.toml | 2 +- core/src/toga/style/applicator.py | 3 +-- core/src/toga/widgets/base.py | 3 ++- core/src/toga/widgets/box.py | 1 + dummy/src/toga_dummy/widgets/base.py | 6 ++++++ dummy/src/toga_dummy/widgets/label.py | 3 --- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/core/pyproject.toml b/core/pyproject.toml index 23e7c9ecf1..03ce4ad2bb 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -9,7 +9,7 @@ relative_files = true # See notes in the root pyproject.toml file. source = ["src"] -source_pkgs = ["toga", "toga_dummy"] +source_pkgs = ["toga"] [tool.coverage.paths] source = [ diff --git a/core/src/toga/style/applicator.py b/core/src/toga/style/applicator.py index 543ecdbcd6..e12d52fd61 100644 --- a/core/src/toga/style/applicator.py +++ b/core/src/toga/style/applicator.py @@ -17,8 +17,7 @@ def set_bounds(self): self.widget.layout.content_height, ) for child in self.widget.children: - if child.applicator: - child.applicator.set_bounds() + child.applicator.set_bounds() def set_text_alignment(self, alignment): self.widget._impl.set_alignment(alignment) diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 76cda05718..48275d6616 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -52,7 +52,8 @@ def __init__( will be applied to the widget. """ super().__init__( - style=style if style else Pack(), applicator=TogaApplicator(self) + style=style if style else Pack(), + applicator=TogaApplicator(self), ) self._id = str(id) if id else str(identifier(self)) diff --git a/core/src/toga/widgets/box.py b/core/src/toga/widgets/box.py index a8e9a77606..c754e65d58 100644 --- a/core/src/toga/widgets/box.py +++ b/core/src/toga/widgets/box.py @@ -23,6 +23,7 @@ def __init__( # Create a platform specific implementation of a Box self._impl = self.factory.Box(interface=self) + # Children need to be added *after* the impl has been created. self._children = [] if children: self.add(*children) diff --git a/dummy/src/toga_dummy/widgets/base.py b/dummy/src/toga_dummy/widgets/base.py index d21a3ab3f2..f1c7a5c3ad 100644 --- a/dummy/src/toga_dummy/widgets/base.py +++ b/dummy/src/toga_dummy/widgets/base.py @@ -40,12 +40,18 @@ def set_tab_index(self, tab_index): def set_bounds(self, x, y, width, height): self._action("set bounds", x=x, y=y, width=width, height=height) + def set_alignment(self, alignment): + self._action("set alignment", alignment=alignment) + def set_hidden(self, hidden): self._action("set hidden", hidden=hidden) def set_font(self, font): self._action("set font", font=font) + def set_color(self, color): + self._action("set color", color=color) + def set_background_color(self, color): self._action("set background color", color=color) diff --git a/dummy/src/toga_dummy/widgets/label.py b/dummy/src/toga_dummy/widgets/label.py index 1c654b249e..2dd5517022 100644 --- a/dummy/src/toga_dummy/widgets/label.py +++ b/dummy/src/toga_dummy/widgets/label.py @@ -5,9 +5,6 @@ class Label(Widget): def create(self): self._action("create Label") - def set_alignment(self, value): - self._set_value("alignment", value) - def get_text(self): return self._get_value("text") From ae1dfc0aafbd1143971aa85dcbea57f5185489b6 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 3 Apr 2023 13:47:48 +0800 Subject: [PATCH 03/24] Add tests for style applicator. --- core/tests/style/test_applicator.py | 100 ++++++++++++++++++++++++++++ core/tests/test_app.py | 2 +- core/tests/widgets/test_base.py | 34 +++++----- core/tests/widgets/test_box.py | 4 +- dummy/src/toga_dummy/utils.py | 10 ++- 5 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 core/tests/style/test_applicator.py diff --git a/core/tests/style/test_applicator.py b/core/tests/style/test_applicator.py new file mode 100644 index 0000000000..601b7f62a6 --- /dev/null +++ b/core/tests/style/test_applicator.py @@ -0,0 +1,100 @@ +import pytest + +import toga +from toga.colors import REBECCAPURPLE +from toga.fonts import FANTASY +from toga.style.pack import RIGHT +from toga_dummy.utils import assert_action_performed_with + + +# Create the simplest possible widget with a concrete implementation that will +# allow children +class TestWidget(toga.Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._impl = self.factory.Widget(self) + self._children = [] + + +# Create the simplest possible widget with a concrete implementation that cannot +# have children. +class TestLeafWidget(toga.Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._impl = self.factory.Widget(self) + + +@pytest.fixture +def child(): + return TestLeafWidget(id="child_id") + + +@pytest.fixture +def widget(child): + widget = TestWidget(id="widget_id") + widget.add(child) + + return widget + + +def test_refresh(widget): + "Refresh requests are passed to the widget" + widget.applicator.refresh() + + assert_action_performed_with(widget, "refresh") + + +def test_set_bounds(child, widget): + "Bounds changes are passed to all widgets in the tree" + # Manually set location of the parent + widget.layout._origin_left = 10 + widget.layout._origin_top = 20 + widget.layout.content_width = 30 + widget.layout.content_height = 40 + + # Manually set location of the child + child.layout._origin_left = 1 + child.layout._origin_top = 2 + child.layout.content_width = 3 + child.layout.content_height = 4 + + # Propegate the boundsq + widget.applicator.set_bounds() + + assert_action_performed_with(widget, "set bounds", x=10, y=20, width=30, height=40) + assert_action_performed_with(child, "set bounds", x=1, y=2, width=3, height=4) + + +def test_text_alignment(widget): + "Text alignment can be set on a widget" + widget.applicator.set_text_alignment(RIGHT) + + assert_action_performed_with(widget, "set alignment", alignment=RIGHT) + + +def test_set_hidden(widget): + "Visibility can be set on a widget" + widget.applicator.set_hidden(True) + + assert_action_performed_with(widget, "set hidden", hidden=True) + + +def test_set_font(widget): + "A font change can be applied to a widget" + widget.applicator.set_font(FANTASY) + + assert_action_performed_with(widget, "set font", font=FANTASY) + + +def test_set_color(widget): + "A color change can be applied to a widget" + widget.applicator.set_color(REBECCAPURPLE) + + assert_action_performed_with(widget, "set color", color=REBECCAPURPLE) + + +def test_set_background_color(child, widget): + "A background color change can be applied to a widget" + widget.applicator.set_background_color(REBECCAPURPLE) + + assert_action_performed_with(widget, "set background color", color=REBECCAPURPLE) diff --git a/core/tests/test_app.py b/core/tests/test_app.py index b396f48933..990d058d3b 100644 --- a/core/tests/test_app.py +++ b/core/tests/test_app.py @@ -165,7 +165,7 @@ async def test_handler(sender): self.app, "loop:call_soon_threadsafe", handler=test_handler, - args=(self.app,), + args=(None,), ) diff --git a/core/tests/widgets/test_base.py b/core/tests/widgets/test_base.py index 6db6a098c5..9dae7f8333 100644 --- a/core/tests/widgets/test_base.py +++ b/core/tests/widgets/test_base.py @@ -106,7 +106,7 @@ def test_add_child_without_app(widget): assert child.window is None # The impl's add_child has been invoked - assert_action_performed_with(widget, "add child", child=child) + assert_action_performed_with(widget, "add child", child=child._impl) def test_add_child(widget): @@ -143,7 +143,7 @@ def test_add_child(widget): assert child.window == window # The impl's add_child has been invoked - assert_action_performed_with(widget, "add child", child=child) + assert_action_performed_with(widget, "add child", child=child._impl) # The window layout has been refreshed window.content.refresh.assert_called_once_with() @@ -200,9 +200,9 @@ def test_add_multiple_children(widget): assert child3.window == window # The impl's add_child has been invoked 3 time - assert_action_performed_with(widget, "add child", child=child1) - assert_action_performed_with(widget, "add child", child=child2) - assert_action_performed_with(widget, "add child", child=child3) + assert_action_performed_with(widget, "add child", child=child1._impl) + assert_action_performed_with(widget, "add child", child=child2._impl) + assert_action_performed_with(widget, "add child", child=child3._impl) # The window layout has been refreshed window.content.refresh.assert_called_once_with() @@ -236,7 +236,7 @@ def test_reparent_child(widget): assert other.children == [] # The impl's add_child has been invoked - assert_action_performed_with(widget, "add child", child=child) + assert_action_performed_with(widget, "add child", child=child._impl) def test_reparent_child_to_self(widget): @@ -306,7 +306,7 @@ def test_insert_child_without_app(widget): assert child.window is None # The impl's insert_child has been invoked - assert_action_performed_with(widget, "insert child", child=child) + assert_action_performed_with(widget, "insert child", child=child._impl) def test_insert_child(widget): @@ -343,7 +343,7 @@ def test_insert_child(widget): assert child.window == window # The impl's insert_child has been invoked - assert_action_performed_with(widget, "insert child", child=child) + assert_action_performed_with(widget, "insert child", child=child._impl) # The window layout has been refreshed window.content.refresh.assert_called_once_with() @@ -402,9 +402,9 @@ def test_insert_position(widget): assert child3.window == window # The impl's insert_child has been invoked 3 time - assert_action_performed_with(widget, "insert child", child=child1) - assert_action_performed_with(widget, "insert child", child=child2) - assert_action_performed_with(widget, "insert child", child=child3) + assert_action_performed_with(widget, "insert child", child=child1._impl) + assert_action_performed_with(widget, "insert child", child=child2._impl) + assert_action_performed_with(widget, "insert child", child=child3._impl) # The window layout has been refreshed on each insertion assert window.content.refresh.mock_calls == [call()] * 3 @@ -452,7 +452,7 @@ def test_insert_bad_position(widget): assert child.window == window # The impl's insert_child has been invoked - assert_action_performed_with(widget, "insert child", child=child) + assert_action_performed_with(widget, "insert child", child=child._impl) # The window layout has been refreshed window.content.refresh.assert_called_once_with() @@ -484,7 +484,7 @@ def test_insert_reparent_child(widget): assert other.children == [] # The impl's insert_child has been invoked - assert_action_performed_with(widget, "insert child", child=child) + assert_action_performed_with(widget, "insert child", child=child._impl) def test_insert_reparent_child_to_self(widget): @@ -534,7 +534,7 @@ def test_remove_child_without_app(widget): assert child.window is None # The impl's remove_child has been invoked - assert_action_performed_with(widget, "remove child", child=child) + assert_action_performed_with(widget, "remove child", child=child._impl) def test_remove_child(widget): @@ -565,7 +565,7 @@ def test_remove_child(widget): assert child.window is None # The impl's remove_child has been invoked - assert_action_performed_with(widget, "remove child", child=child) + assert_action_performed_with(widget, "remove child", child=child._impl) # The window layout has been refreshed window.content.refresh.assert_called_once_with() @@ -610,8 +610,8 @@ def test_remove_multiple_children(widget): assert child3.window is None # The impl's remove_child has been invoked twice - assert_action_performed_with(widget, "remove child", child=child1) - assert_action_performed_with(widget, "remove child", child=child3) + assert_action_performed_with(widget, "remove child", child=child1._impl) + assert_action_performed_with(widget, "remove child", child=child3._impl) # The window layout has been refreshed once window.content.refresh.assert_called_once_with() diff --git a/core/tests/widgets/test_box.py b/core/tests/widgets/test_box.py index 9ce659aa1c..c45d68d542 100644 --- a/core/tests/widgets/test_box.py +++ b/core/tests/widgets/test_box.py @@ -26,8 +26,8 @@ def test_create_box_with_children(): assert box._impl.interface == box assert_action_performed(box, "create Box") - assert_action_performed_with(box, "add child", child=child1) - assert_action_performed_with(box, "add child", child=child2) + assert_action_performed_with(box, "add child", child=child1._impl) + assert_action_performed_with(box, "add child", child=child2._impl) # But the box will have children. assert box.children == [child1, child2] diff --git a/dummy/src/toga_dummy/utils.py b/dummy/src/toga_dummy/utils.py index cb2b39f2c4..e2916504a6 100644 --- a/dummy/src/toga_dummy/utils.py +++ b/dummy/src/toga_dummy/utils.py @@ -397,7 +397,8 @@ def assert_action_performed_with(_widget, _action, **test_data): try: # Iterate over every action that was performed on # this object. - for data in EventLog.performed_actions(_widget, _action): + actions_performed = EventLog.performed_actions(_widget, _action) + for data in actions_performed: found = True # Iterate over every key and value in the test # data. If the value in the recorded action @@ -428,6 +429,13 @@ def assert_action_performed_with(_widget, _action, **test_data): # with the next recorded action. if found: return + + # None of the recorded actions match the test data. + pytest.fail( + f"Widget {_widget} did not perform action {_action!r} " + f"with data {test_data!r}; " + f"actions performed were {actions_performed!r}" + ) except AttributeError as e: # None of the recorded actions match the test data. pytest.fail(str(e)) From 34a22ed1bc2a91df3e943fe491e9f57e6172b26b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 3 Apr 2023 15:16:18 +0800 Subject: [PATCH 04/24] Correct an issue with Pack with fixed width/height on widgets with children. --- core/src/toga/style/pack.py | 25 ++++++---- core/tests/style/test_pack.py | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index 3462bb0ee8..3d498ae05b 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -197,17 +197,25 @@ def _layout_node(self, node, alloc_width, alloc_height, scale, root=False): width, height = self._layout_row_children( node, available_width, available_height, scale ) - - if root: - # self._debug("ROOT NODE") - width = max(width, available_width) - height = max(height, available_height) - else: # self._debug("NO CHILDREN", available_width) width = available_width height = available_height + if root: + # A root node always expands to all available width and height, + # no matter how much space the child layout requires. + # self._debug("ROOT NODE") + width = max(width, available_width) + height = max(height, available_height) + else: + # If an explicit width/height was given, that specification + # overrides the width/height evaluated by the layout of children + if self.width: + width = scale(self.width) + if self.height: + height = scale(self.height) + # self._debug("FINAL SIZE", width, height) node.layout.content_width = int(width) node.layout.content_height = int(height) @@ -280,9 +288,10 @@ def _layout_row_children(self, node, available_width, available_height, scale): width += child_width available_width -= child_width - available_width = max(0, available_width) + available_width = max(0, available_width) + if full_flex: - # self._debug("q =",available_width, full_flex, available_width / full_flex) + # self._debug("q =", available_width, full_flex, available_width / full_flex) quantum = available_width / full_flex else: quantum = 0 diff --git a/core/tests/style/test_pack.py b/core/tests/style/test_pack.py index c2db14d701..b8d1167f50 100644 --- a/core/tests/style/test_pack.py +++ b/core/tests/style/test_pack.py @@ -1777,3 +1777,91 @@ def test_rtl_alignment_right(self): ], }, ) + + def test_fixed_size(self): + root = TestNode( + "app", + style=Pack(), + children=[ + TestNode( + "first", + style=Pack(width=100, height=100), + size=(at_least(0), at_least(0)), + ), + TestNode( + "second", + style=Pack(width=100, height=100), + size=(at_least(50), at_least(50)), + children=[ + TestNode( + "child", + style=Pack(width=50, height=50), + size=(at_least(0), at_least(0)), + ), + ], + ), + ], + ) + + # Minimum size + root.style.layout(root, TestViewport(0, 0, dpi=96)) + self.assertLayout( + root, + (200, 100), + { + "origin": (0, 0), + "content": (200, 100), + "children": [ + {"origin": (0, 0), "content": (100, 100)}, + { + "origin": (100, 0), + "content": (100, 100), + "children": [ + {"origin": (100, 0), "content": (50, 50)}, + ], + }, + ], + }, + ) + + # Normal size + root.style.layout(root, TestViewport(640, 480, dpi=96)) + self.assertLayout( + root, + (640, 480), + { + "origin": (0, 0), + "content": (640, 480), + "children": [ + {"origin": (0, 0), "content": (100, 100)}, + { + "origin": (100, 0), + "content": (100, 100), + "children": [ + {"origin": (100, 0), "content": (50, 50)}, + ], + }, + ], + }, + ) + + # HiDPI Normal size + root.style.layout(root, TestViewport(640, 480, dpi=144)) + self.assertLayout( + root, + (640, 480), + { + "origin": (0, 0), + "content": (640, 480), + "children": [ + {"origin": (0, 0), "content": (150, 150)}, + { + "origin": (150, 0), + "content": (150, 150), + "children": [ + {"origin": (150, 0), "content": (75, 75)}, + ], + }, + ], + }, + ) From d79bf87aee46d99af8bb377f2b9d06b814d036a4 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 3 Apr 2023 15:16:57 +0800 Subject: [PATCH 05/24] Improve coverage by ignoring nocover lines and removing unreachable edge cases. --- cocoa/src/toga_cocoa/widgets/base.py | 7 +++---- testbed/tests/testbed.py | 6 ++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/base.py b/cocoa/src/toga_cocoa/widgets/base.py index 5a74230813..46c651345a 100644 --- a/cocoa/src/toga_cocoa/widgets/base.py +++ b/cocoa/src/toga_cocoa/widgets/base.py @@ -72,8 +72,7 @@ def set_alignment(self, alignment): pass def set_hidden(self, hidden): - if self.native: - self.native.setHidden(hidden) + self.native.setHidden(hidden) def set_font(self, font): pass @@ -92,10 +91,10 @@ def focus(self): self.interface.window._impl.native.makeFirstResponder(self.native) def get_tab_index(self): - self.interface.factory.not_implementated("Widget.get_tab_index()") + self.interface.factory.not_implemented("Widget.get_tab_index()") def set_tab_index(self, tab_index): - self.interface.factory.not_implementated("Widget.set_tab_index()") + self.interface.factory.not_implemented("Widget.set_tab_index()") # INTERFACE diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 81d28658e6..98e8ecb0fa 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -56,6 +56,12 @@ def run_tests(app, cov, args, report_coverage, run_slow): if app.returncode == 0: cov.stop() if report_coverage: + # Exclude some patterns of lines that can't have coverage + cov.exclude("pragma: no cover") + cov.exclude("@(abc\\.)?abstractmethod") + cov.exclude("NotImplementedError") + cov.exclude("\\.not_implemented\\(") + total = cov.report( precision=1, skip_covered=True, From 511d6028c283eb23d5d6f0ebf2deb616cfcefc47 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 07:26:05 +0800 Subject: [PATCH 06/24] Add test probe and tests for Cocoa Box. --- cocoa/src/toga_cocoa/widgets/base.py | 18 ++++--- .../src/toga_cocoa/widgets/optioncontainer.py | 6 +++ .../src/toga_cocoa/widgets/splitcontainer.py | 6 +++ cocoa/tests_backend/widgets/base.py | 18 +++++++ core/src/toga/widgets/activityindicator.py | 4 ++ core/src/toga/widgets/base.py | 2 +- core/src/toga/widgets/box.py | 4 ++ core/src/toga/widgets/divider.py | 4 ++ core/src/toga/widgets/label.py | 4 ++ core/src/toga/widgets/optioncontainer.py | 3 ++ core/src/toga/widgets/splitcontainer.py | 3 ++ testbed/tests/widgets/properties.py | 40 +++++++++++++- .../tests/widgets/test_activityindicator.py | 1 + testbed/tests/widgets/test_box.py | 53 ++++++++++++++++++- testbed/tests/widgets/test_button.py | 1 + testbed/tests/widgets/test_divider.py | 1 + testbed/tests/widgets/test_label.py | 1 + testbed/tests/widgets/test_switch.py | 1 + 18 files changed, 159 insertions(+), 11 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/base.py b/cocoa/src/toga_cocoa/widgets/base.py index 46c651345a..8732c7a109 100644 --- a/cocoa/src/toga_cocoa/widgets/base.py +++ b/cocoa/src/toga_cocoa/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from toga.colors import TRANSPARENT from toga_cocoa.colors import native_color from toga_cocoa.constraints import Constraints @@ -30,13 +32,12 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # existing container should be removed - self.constraints.container = None - self._container = None - self.native.removeFromSuperview() + assert container is None, "Widget already has a container" + + # Existing container should be removed + self.constraints.container = None + self._container = None + self.native.removeFromSuperview() elif container: # setting container self._container = container @@ -118,5 +119,6 @@ def add_constraints(self): def refresh(self): self.rehint() + @abstractmethod def rehint(self): - pass + ... diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index a68e27ab80..d509d942c0 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -1,3 +1,5 @@ +from travertino.size import at_least + from toga_cocoa.libs import NSObject, NSTabView, NSTabViewItem, objc_method from toga_cocoa.window import CocoaViewport @@ -112,3 +114,7 @@ def get_current_tab_index(self): def set_current_tab_index(self, current_tab_index): self.native.selectTabViewItemAtIndex(current_tab_index) + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/src/toga_cocoa/widgets/splitcontainer.py b/cocoa/src/toga_cocoa/widgets/splitcontainer.py index 0003616f35..9410925132 100644 --- a/cocoa/src/toga_cocoa/widgets/splitcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/splitcontainer.py @@ -1,3 +1,5 @@ +from travertino.size import at_least + from toga_cocoa.libs import NSObject, NSSize, NSSplitView, objc_method from toga_cocoa.window import CocoaViewport @@ -79,3 +81,7 @@ def set_direction(self, value): def on_resize(self): pass + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index de279f46b7..3b2dcb0c01 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -20,6 +20,11 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.superview is None + assert self.native.window is None + def assert_alignment(self, expected): assert self.alignment == expected @@ -58,6 +63,16 @@ def width(self): def height(self): return self.native.frame.size.height + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.superview is not None + assert self.native.window is not None + + # size and position is as expected. + assert (self.native.frame.size.width, self.native.frame.size.height) == size + assert (self.native.frame.origin.x, self.native.frame.origin.y) == position + def assert_width(self, min_width, max_width): assert ( min_width <= self.width <= max_width @@ -80,3 +95,6 @@ def background_color(self): def press(self): self.native.performClick(None) + + def has_focus(self): + return self.native.window.firstResponder == self.native diff --git a/core/src/toga/widgets/activityindicator.py b/core/src/toga/widgets/activityindicator.py index b963ffd9fa..d11ffaed75 100644 --- a/core/src/toga/widgets/activityindicator.py +++ b/core/src/toga/widgets/activityindicator.py @@ -26,6 +26,10 @@ def enabled(self, value): # ActivityIndicator doesn't have a "disabled" state pass + def focus(self): + "No-op; ActivityIndicator cannot accept input focus" + pass + @property def is_running(self): """Determine if the activity indicator is currently running. diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 48275d6616..4b313962b5 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -247,5 +247,5 @@ def refresh_sublayouts(self): child.refresh_sublayouts() def focus(self): - """Give this widget to have the input focus.""" + """Give this widget the input focus.""" self._impl.focus() diff --git a/core/src/toga/widgets/box.py b/core/src/toga/widgets/box.py index c754e65d58..0ba6a8585d 100644 --- a/core/src/toga/widgets/box.py +++ b/core/src/toga/widgets/box.py @@ -32,3 +32,7 @@ def __init__( def enabled(self, value): # Box doesn't have a "disabled" state pass + + def focus(self): + "No-op; ActivityIndicator cannot accept input focus" + pass diff --git a/core/src/toga/widgets/divider.py b/core/src/toga/widgets/divider.py index 0e70e5957c..0772044f7e 100644 --- a/core/src/toga/widgets/divider.py +++ b/core/src/toga/widgets/divider.py @@ -32,6 +32,10 @@ def enabled(self, value): # Divider doesn't have a "disabled" state pass + def focus(self): + "No-op; Divider cannot accept input focus" + pass + @property def direction(self): """The direction in which the visual separator will be drawn. diff --git a/core/src/toga/widgets/label.py b/core/src/toga/widgets/label.py index 6540f95b5e..2ecedbb7ae 100644 --- a/core/src/toga/widgets/label.py +++ b/core/src/toga/widgets/label.py @@ -24,6 +24,10 @@ def __init__( self.text = text + def focus(self): + "No-op; Label cannot accept input focus" + pass + @property def text(self): """The text displayed by the label. diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index c92e807844..5e48c9ba74 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -270,6 +270,9 @@ def _insert(self, index, text, widget, enabled=True): class OptionContainer(Widget): + _MIN_WIDTH = 100 + _MIN_HEIGHT = 100 + """The option container widget. Args: diff --git a/core/src/toga/widgets/splitcontainer.py b/core/src/toga/widgets/splitcontainer.py index 1f2aa19809..0b05c4a19d 100644 --- a/core/src/toga/widgets/splitcontainer.py +++ b/core/src/toga/widgets/splitcontainer.py @@ -21,6 +21,9 @@ class SplitContainer(Widget): flex (boolean): Should the content expand when the widget is resized. (optional) """ + _MIN_HEIGHT = 100 + _MIN_WIDTH = 100 + HORIZONTAL = False VERTICAL = True diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index 66ed9ef8aa..a5dd8c6ab3 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -1,11 +1,13 @@ from pytest import approx +import toga from toga.colors import RED, TRANSPARENT, color as named_color from toga.fonts import BOLD, FANTASY, ITALIC, NORMAL, SERIF, SYSTEM from toga.style.pack import COLUMN from ..assertions import assert_color from ..data import COLORS, TEXTS +from .probe import get_probe # An upper bound for widths MAX_WIDTH = 2000 @@ -47,6 +49,42 @@ async def test_enable_noop(widget, probe): assert probe.enabled +# async def test_hidden(widget, probe): + + +async def test_focus(widget, probe): + "The widget can be given focus" + # Add a separate widget that can take take focus + other = toga.Button("Other") + widget.parent.add(other) + other_probe = get_probe(other) + + other.focus() + assert not probe.has_focus() + assert other_probe.has_focus() + + widget.focus() + assert probe.has_focus() + assert not other_probe.has_focus() + + +async def test_focus_noop(widget, probe): + "The widget cannot be given focus" + # Add a separate widget that can take take focus + other = toga.Button("Other") + widget.parent.add(other) + other_probe = get_probe(other) + + other.focus() + assert not probe.has_focus() + assert other_probe.has_focus() + + # Widget has *not* taken focus + widget.focus() + assert not probe.has_focus() + assert other_probe.has_focus() + + async def test_text(widget, probe): "The text displayed on a widget can be changed" for text in TEXTS: @@ -197,7 +235,7 @@ async def test_flex_widget_size(widget, probe): # Check the initial widget size # Match isn't exact because of pixel scaling on some platforms assert probe.width == approx(100, rel=0.01) - assert probe.height == approx(100, rel=0.01) + assert probe.height == approx(200, rel=0.01) # Drop the fixed height, and make the widget flexible widget.style.flex = 1 diff --git a/testbed/tests/widgets/test_activityindicator.py b/testbed/tests/widgets/test_activityindicator.py index 71988ee57d..2e498a7f20 100644 --- a/testbed/tests/widgets/test_activityindicator.py +++ b/testbed/tests/widgets/test_activityindicator.py @@ -5,6 +5,7 @@ from ..conftest import skip_on_platforms from .properties import ( # noqa: F401 test_enable_noop, + test_focus_noop, ) diff --git a/testbed/tests/widgets/test_box.py b/testbed/tests/widgets/test_box.py index 575a6c31f6..02858c1b63 100644 --- a/testbed/tests/widgets/test_box.py +++ b/testbed/tests/widgets/test_box.py @@ -1,16 +1,67 @@ import pytest import toga +from toga.colors import BLACK, BLUE, GREEN, RED from toga.style import Pack +from .probe import get_probe from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, test_enable_noop, test_flex_widget_size, + test_focus_noop, ) @pytest.fixture async def widget(): - return toga.Box(style=Pack(width=100, height=100)) + return toga.Box(style=Pack(width=100, height=200)) + + +async def test_parenting(widget, probe): + widget.style.background_color = RED + box = widget.parent + + child = toga.Box(style=Pack(width=50, height=75, background_color=GREEN)) + child_probe = get_probe(child) + other = toga.Box(style=Pack(width=100, height=200, background_color=BLUE)) + other_probe = get_probe(other) + other_child = toga.Box(style=Pack(width=25, height=50, background_color=BLACK)) + other.add(other_child) + + # Layout has the test widget, plus other, horizontally laid out. + # Child isn't in the layout yet. + box.add(other) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_not_contained() + + # Add child to widget. + widget.add(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_layout(position=(0, 0), size=(50, 75)) + + # Reparent child to other without removing first + other.add(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_layout(position=(125, 0), size=(50, 75)) + + # Remove child from the layout entirely + other.remove(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_not_contained() + + # Insert the child into the root layout + box.insert(1, child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(150, 0), size=(100, 200)) + child_probe.assert_layout(position=(100, 0), size=(50, 75)) diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py index 7aa387848c..24914c7f4f 100644 --- a/testbed/tests/widgets/test_button.py +++ b/testbed/tests/widgets/test_button.py @@ -14,6 +14,7 @@ test_color_reset, test_enabled, test_flex_horizontal_widget_size, + test_focus, test_font, test_font_attrs, test_text_width_change, diff --git a/testbed/tests/widgets/test_divider.py b/testbed/tests/widgets/test_divider.py index 7ff9f2795c..db2e137c4d 100644 --- a/testbed/tests/widgets/test_divider.py +++ b/testbed/tests/widgets/test_divider.py @@ -6,6 +6,7 @@ from ..conftest import skip_on_platforms from .properties import ( # noqa: F401 test_enable_noop, + test_focus_noop, ) diff --git a/testbed/tests/widgets/test_label.py b/testbed/tests/widgets/test_label.py index d9a1f6bc35..fdf3d2a8f0 100644 --- a/testbed/tests/widgets/test_label.py +++ b/testbed/tests/widgets/test_label.py @@ -11,6 +11,7 @@ test_color_reset, test_enabled, test_flex_horizontal_widget_size, + test_focus_noop, test_font, test_font_attrs, test_text, diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py index bf3414b0c7..8b17ced19b 100644 --- a/testbed/tests/widgets/test_switch.py +++ b/testbed/tests/widgets/test_switch.py @@ -10,6 +10,7 @@ test_color_reset, test_enabled, test_flex_horizontal_widget_size, + test_focus, test_font, test_font_attrs, test_text_width_change, From 1b24ce9c5f0cc31e7b12ed800c5ec4460d3faa76 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 07:49:03 +0800 Subject: [PATCH 07/24] Make rehint an abstract method on iOS; factor out common MIN_WIDTH/HEIGHT. --- .../widgets/multilinetextinput.py | 4 ++-- android/src/toga_android/widgets/textinput.py | 2 +- android/src/toga_android/widgets/webview.py | 2 +- cocoa/src/toga_cocoa/widgets/detailedlist.py | 4 ++-- .../toga_cocoa/widgets/multilinetextinput.py | 4 ++-- cocoa/src/toga_cocoa/widgets/numberinput.py | 2 +- cocoa/src/toga_cocoa/widgets/progressbar.py | 2 +- .../src/toga_cocoa/widgets/scrollcontainer.py | 4 ++-- cocoa/src/toga_cocoa/widgets/selection.py | 2 +- cocoa/src/toga_cocoa/widgets/slider.py | 2 +- cocoa/src/toga_cocoa/widgets/table.py | 4 ++-- cocoa/src/toga_cocoa/widgets/textinput.py | 2 +- cocoa/src/toga_cocoa/widgets/tree.py | 4 ++-- cocoa/src/toga_cocoa/widgets/webview.py | 4 ++-- core/src/toga/widgets/base.py | 3 +++ core/src/toga/widgets/datepicker.py | 2 -- core/src/toga/widgets/numberinput.py | 2 -- core/src/toga/widgets/optioncontainer.py | 3 --- core/src/toga/widgets/progressbar.py | 2 -- core/src/toga/widgets/selection.py | 2 -- core/src/toga/widgets/table.py | 3 --- core/src/toga/widgets/webview.py | 3 --- gtk/src/toga_gtk/widgets/detailedlist.py | 4 ++-- .../toga_gtk/widgets/multilinetextinput.py | 4 ++-- gtk/src/toga_gtk/widgets/numberinput.py | 2 +- gtk/src/toga_gtk/widgets/scrollcontainer.py | 4 ++-- gtk/src/toga_gtk/widgets/selection.py | 2 +- gtk/src/toga_gtk/widgets/slider.py | 2 +- gtk/src/toga_gtk/widgets/textinput.py | 2 +- gtk/src/toga_gtk/widgets/webview.py | 4 ++-- iOS/src/toga_iOS/widgets/base.py | 24 ++++++++++--------- iOS/src/toga_iOS/widgets/detailedlist.py | 5 ++++ iOS/src/toga_iOS/widgets/imageview.py | 6 +++++ .../toga_iOS/widgets/multilinetextinput.py | 4 ++-- iOS/src/toga_iOS/widgets/navigationview.py | 5 ++++ iOS/src/toga_iOS/widgets/progressbar.py | 2 +- iOS/src/toga_iOS/widgets/scrollcontainer.py | 4 ++++ iOS/src/toga_iOS/widgets/webview.py | 5 ++++ .../src/toga_winforms/widgets/datepicker.py | 2 +- .../src/toga_winforms/widgets/detailedlist.py | 4 ++-- .../widgets/multilinetextinput.py | 4 ++-- .../src/toga_winforms/widgets/numberinput.py | 2 +- .../src/toga_winforms/widgets/progressbar.py | 2 +- winforms/src/toga_winforms/widgets/table.py | 4 ++-- .../src/toga_winforms/widgets/textinput.py | 2 +- .../src/toga_winforms/widgets/timepicker.py | 2 +- winforms/src/toga_winforms/widgets/webview.py | 4 ++-- 47 files changed, 90 insertions(+), 77 deletions(-) diff --git a/android/src/toga_android/widgets/multilinetextinput.py b/android/src/toga_android/widgets/multilinetextinput.py index fde825eda8..8df65be25f 100644 --- a/android/src/toga_android/widgets/multilinetextinput.py +++ b/android/src/toga_android/widgets/multilinetextinput.py @@ -65,8 +65,8 @@ def set_on_change(self, handler): self.native.addTextChangedListener(self._textChangedListener) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def scroll_to_bottom(self): last_line = (self.native.getLineCount() - 1) * self.native.getLineHeight() diff --git a/android/src/toga_android/widgets/textinput.py b/android/src/toga_android/widgets/textinput.py index 4a21327708..b9b7934663 100644 --- a/android/src/toga_android/widgets/textinput.py +++ b/android/src/toga_android/widgets/textinput.py @@ -68,7 +68,7 @@ def is_valid(self): self.interface.factory.not_implemented("TextInput.is_valid()") def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) # Refuse to call measure() if widget has no container, i.e., has no LayoutParams. # On Android, EditText's measure() throws NullPointerException if the widget has no # LayoutParams. diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index bf6f990725..c33b9a59a1 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -80,7 +80,7 @@ def set_alignment(self, value): self.native.setGravity(Gravity.CENTER_VERTICAL | align(value)) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) # Refuse to call measure() if widget has no container, i.e., has no LayoutParams. # Android's measure() throws NullPointerException if the widget has no LayoutParams. if not self.native.getLayoutParams(): diff --git a/cocoa/src/toga_cocoa/widgets/detailedlist.py b/cocoa/src/toga_cocoa/widgets/detailedlist.py index 78bf043843..0f3105f8e1 100644 --- a/cocoa/src/toga_cocoa/widgets/detailedlist.py +++ b/cocoa/src/toga_cocoa/widgets/detailedlist.py @@ -182,5 +182,5 @@ def scroll_to_row(self, row): self.detailedlist.scrollRowToVisible(row) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py index 9323a97c41..010fc4527d 100644 --- a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py +++ b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py @@ -68,8 +68,8 @@ def set_font(self, font): self.text.font = font._impl.native def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + 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): self.interface.factory.not_implemented("MultilineTextInput.set_on_change()") diff --git a/cocoa/src/toga_cocoa/widgets/numberinput.py b/cocoa/src/toga_cocoa/widgets/numberinput.py index 90064e1b33..c6d5474056 100644 --- a/cocoa/src/toga_cocoa/widgets/numberinput.py +++ b/cocoa/src/toga_cocoa/widgets/numberinput.py @@ -214,7 +214,7 @@ def rehint(self): stepper_size = self.input.intrinsicContentSize() input_size = self.input.intrinsicContentSize() - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = max(input_size.height, stepper_size.height) def set_on_change(self, handler): diff --git a/cocoa/src/toga_cocoa/widgets/progressbar.py b/cocoa/src/toga_cocoa/widgets/progressbar.py index bb101e5a2a..cde5af8cb5 100644 --- a/cocoa/src/toga_cocoa/widgets/progressbar.py +++ b/cocoa/src/toga_cocoa/widgets/progressbar.py @@ -31,5 +31,5 @@ def set_max(self, value): self.native.indeterminate = True def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.intrinsicContentSize().height diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 198a0d08d0..2ab06edf87 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -56,8 +56,8 @@ def set_horizontal(self, value): self.interface.refresh() def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def set_on_scroll(self, on_scroll): self.interface.factory.not_implemented("ScrollContainer.set_on_scroll()") diff --git a/cocoa/src/toga_cocoa/widgets/selection.py b/cocoa/src/toga_cocoa/widgets/selection.py index 71a11a10f2..9081f40c8c 100644 --- a/cocoa/src/toga_cocoa/widgets/selection.py +++ b/cocoa/src/toga_cocoa/widgets/selection.py @@ -30,7 +30,7 @@ def rehint(self): content_size = self.native.intrinsicContentSize() self.interface.intrinsic.height = content_size.height self.interface.intrinsic.width = at_least( - max(self.interface.MIN_WIDTH, content_size.width) + max(self.interface._MIN_WIDTH, content_size.width) ) def remove_all_items(self): diff --git a/cocoa/src/toga_cocoa/widgets/slider.py b/cocoa/src/toga_cocoa/widgets/slider.py index dc15cefa4b..1752d1f993 100644 --- a/cocoa/src/toga_cocoa/widgets/slider.py +++ b/cocoa/src/toga_cocoa/widgets/slider.py @@ -59,4 +59,4 @@ def set_range(self, range): def rehint(self): content_size = self.native.intrinsicContentSize() self.interface.intrinsic.height = content_size.height - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 6fa2b5281a..c533b1fa9c 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -251,8 +251,8 @@ def scroll_to_row(self, row): self.table.scrollRowToVisible(row) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def _add_column(self, heading, accessor): column_identifier = at(accessor) diff --git a/cocoa/src/toga_cocoa/widgets/textinput.py b/cocoa/src/toga_cocoa/widgets/textinput.py index af3eb4ae41..a7f6de38ab 100644 --- a/cocoa/src/toga_cocoa/widgets/textinput.py +++ b/cocoa/src/toga_cocoa/widgets/textinput.py @@ -95,7 +95,7 @@ def rehint(self): # print("REHINT TextInput", self, # self._impl.intrinsicContentSize().width, self._impl.intrinsicContentSize().height # ) - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.intrinsicContentSize().height def set_on_change(self, handler): diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 4d49456179..a12c7c261d 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -319,5 +319,5 @@ def set_on_double_click(self, handler): pass def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 4d54713dce..3a5284f025 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -117,5 +117,5 @@ def invoke_javascript(self, javascript): self.native.evaluateJavaScript(javascript, completionHandler=None) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 4b313962b5..a3c3334c93 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -38,6 +38,9 @@ def __iter__(self): class Widget(Node): + _MIN_WIDTH = 100 + _MIN_HEIGHT = 100 + def __init__( self, id=None, diff --git a/core/src/toga/widgets/datepicker.py b/core/src/toga/widgets/datepicker.py index 5d78c9ea25..eee1a2ee04 100644 --- a/core/src/toga/widgets/datepicker.py +++ b/core/src/toga/widgets/datepicker.py @@ -15,8 +15,6 @@ class DatePicker(Widget): a new one will be created for the widget. """ - MIN_WIDTH = 200 - def __init__( self, id=None, diff --git a/core/src/toga/widgets/numberinput.py b/core/src/toga/widgets/numberinput.py index 81c41ef9da..5e96c53fb0 100644 --- a/core/src/toga/widgets/numberinput.py +++ b/core/src/toga/widgets/numberinput.py @@ -27,8 +27,6 @@ class NumberInput(Widget): **ex: """ - MIN_WIDTH = 100 - def __init__( self, id=None, diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index 5e48c9ba74..c92e807844 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -270,9 +270,6 @@ def _insert(self, index, text, widget, enabled=True): class OptionContainer(Widget): - _MIN_WIDTH = 100 - _MIN_HEIGHT = 100 - """The option container widget. Args: diff --git a/core/src/toga/widgets/progressbar.py b/core/src/toga/widgets/progressbar.py index ab877f58a2..f21221a57e 100644 --- a/core/src/toga/widgets/progressbar.py +++ b/core/src/toga/widgets/progressbar.py @@ -6,8 +6,6 @@ class ProgressBar(Widget): """""" - MIN_WIDTH = 100 - def __init__( self, id=None, diff --git a/core/src/toga/widgets/selection.py b/core/src/toga/widgets/selection.py index 8596c26436..31c3bb6a9c 100644 --- a/core/src/toga/widgets/selection.py +++ b/core/src/toga/widgets/selection.py @@ -15,8 +15,6 @@ class Selection(Widget): items (``list`` of ``str``): The items for the selection. """ - MIN_WIDTH = 100 - def __init__( self, id=None, diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 18102e1edd..5d6bdaa04c 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -47,9 +47,6 @@ class Table(Widget): >>> data = ['item 1', 'item 2', 'item 3'] """ - MIN_WIDTH = 100 - MIN_HEIGHT = 100 - def __init__( self, headings, diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 604946fea0..517996a67e 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -22,9 +22,6 @@ class WebView(Widget): :type on_webview_load: ``callable`` """ - MIN_WIDTH = 100 - MIN_HEIGHT = 100 - def __init__( self, id=None, diff --git a/gtk/src/toga_gtk/widgets/detailedlist.py b/gtk/src/toga_gtk/widgets/detailedlist.py index a2ab375e8d..bdf9d137b6 100644 --- a/gtk/src/toga_gtk/widgets/detailedlist.py +++ b/gtk/src/toga_gtk/widgets/detailedlist.py @@ -32,8 +32,8 @@ def create(self): self.scrolled_window = Gtk.ScrolledWindow() self.scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - self.scrolled_window.set_min_content_width(self.interface.MIN_WIDTH) - self.scrolled_window.set_min_content_height(self.interface.MIN_HEIGHT) + self.scrolled_window.set_min_content_width(self.interface._MIN_WIDTH) + self.scrolled_window.set_min_content_height(self.interface._MIN_HEIGHT) self.scrolled_window.add(self.list_box) diff --git a/gtk/src/toga_gtk/widgets/multilinetextinput.py b/gtk/src/toga_gtk/widgets/multilinetextinput.py index 2827895250..8fdb0ce81c 100644 --- a/gtk/src/toga_gtk/widgets/multilinetextinput.py +++ b/gtk/src/toga_gtk/widgets/multilinetextinput.py @@ -68,8 +68,8 @@ def gtk_on_focus_out(self, *args): return False def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + 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): self.interface.factory.not_implemented("MultilineTextInput.set_on_change()") diff --git a/gtk/src/toga_gtk/widgets/numberinput.py b/gtk/src/toga_gtk/widgets/numberinput.py index 04697824bc..0988de1568 100644 --- a/gtk/src/toga_gtk/widgets/numberinput.py +++ b/gtk/src/toga_gtk/widgets/numberinput.py @@ -62,7 +62,7 @@ def rehint(self): width = self.native.get_preferred_width() height = self.native.get_preferred_height() if width and height: - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = height[1] def set_on_change(self, handler): diff --git a/gtk/src/toga_gtk/widgets/scrollcontainer.py b/gtk/src/toga_gtk/widgets/scrollcontainer.py index 0cad6c400f..6e468e60ad 100644 --- a/gtk/src/toga_gtk/widgets/scrollcontainer.py +++ b/gtk/src/toga_gtk/widgets/scrollcontainer.py @@ -9,8 +9,8 @@ def create(self): # Set this minimum size of scroll windows because we must reserve space for # scrollbars when splitter resized. See, https://gitlab.gnome.org/GNOME/gtk/-/issues/210 - self.native.set_min_content_width(self.interface.MIN_WIDTH) - self.native.set_min_content_height(self.interface.MIN_HEIGHT) + self.native.set_min_content_width(self.interface._MIN_WIDTH) + self.native.set_min_content_height(self.interface._MIN_HEIGHT) self.native.set_overlay_scrolling(True) diff --git a/gtk/src/toga_gtk/widgets/selection.py b/gtk/src/toga_gtk/widgets/selection.py index 27c1822aa2..26e8ce42d2 100644 --- a/gtk/src/toga_gtk/widgets/selection.py +++ b/gtk/src/toga_gtk/widgets/selection.py @@ -33,7 +33,7 @@ def rehint(self): # width = self.native.get_preferred_width() height = self.native.get_preferred_height() - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = height[1] def set_on_select(self, handler): diff --git a/gtk/src/toga_gtk/widgets/slider.py b/gtk/src/toga_gtk/widgets/slider.py index ab3d757900..d7e159c6a6 100644 --- a/gtk/src/toga_gtk/widgets/slider.py +++ b/gtk/src/toga_gtk/widgets/slider.py @@ -32,6 +32,6 @@ def rehint(self): height = self.native.get_preferred_height() # Set intrinsic width to at least the minimum width - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) # Set intrinsic height to the natural height self.interface.intrinsic.height = height[1] diff --git a/gtk/src/toga_gtk/widgets/textinput.py b/gtk/src/toga_gtk/widgets/textinput.py index 0192719deb..77f1f82bfc 100644 --- a/gtk/src/toga_gtk/widgets/textinput.py +++ b/gtk/src/toga_gtk/widgets/textinput.py @@ -53,7 +53,7 @@ def rehint(self): # width = self.native.get_preferred_width() height = self.native.get_preferred_height() - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = height[1] def set_on_change(self, handler): diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index 9fc0b25d95..f73c93465d 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -96,5 +96,5 @@ def invoke_javascript(self, javascript): self.native.run_javascript(javascript, None, None) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/iOS/src/toga_iOS/widgets/base.py b/iOS/src/toga_iOS/widgets/base.py index 03cf1e251e..573d709d79 100644 --- a/iOS/src/toga_iOS/widgets/base.py +++ b/iOS/src/toga_iOS/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from toga.colors import TRANSPARENT from toga_iOS.colors import native_color from toga_iOS.constraints import Constraints @@ -31,13 +33,12 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # existing container should be removed - self.constraints = None - self._container = None - self.native.removeFromSuperview() + assert container is None, "Widget already has a container" + + # Existing container should be removed + self.constraints = None + self._container = None + self.native.removeFromSuperview() elif container: # setting container self._container = container @@ -66,13 +67,13 @@ def set_enabled(self, value): self.native.enabled = value def focus(self): - self.interface.factory.not_implemented("Widget.focus()") + self.native.becomeFirstResponder() def get_tab_index(self): - self.interface.factory.not_implementated("Widget.get_tab_index()") + self.interface.factory.not_implemented("Widget.get_tab_index()") def set_tab_index(self, tab_index): - self.interface.factory.not_implementated("Widget.set_tab_index()") + self.interface.factory.not_implemented("Widget.set_tab_index()") # APPLICATOR @@ -137,5 +138,6 @@ def add_constraints(self): def refresh(self): self.rehint() + @abstractmethod def rehint(self): - pass + ... diff --git a/iOS/src/toga_iOS/widgets/detailedlist.py b/iOS/src/toga_iOS/widgets/detailedlist.py index 9b6fcf3eaa..5706ef0016 100644 --- a/iOS/src/toga_iOS/widgets/detailedlist.py +++ b/iOS/src/toga_iOS/widgets/detailedlist.py @@ -1,4 +1,5 @@ from rubicon.objc import SEL, objc_method, objc_property +from travertino.size import at_least from toga_iOS.libs import ( NSIndexPath, @@ -151,3 +152,7 @@ def scroll_to_row(self, row): atScrollPosition=UITableViewScrollPositionNone, animated=False, ) + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/iOS/src/toga_iOS/widgets/imageview.py b/iOS/src/toga_iOS/widgets/imageview.py index 32cb36677b..6cb5286e36 100644 --- a/iOS/src/toga_iOS/widgets/imageview.py +++ b/iOS/src/toga_iOS/widgets/imageview.py @@ -1,3 +1,5 @@ +from travertino.size import at_least + from toga_iOS.libs import UIImageView, UIViewContentMode from toga_iOS.widgets.base import Widget @@ -19,3 +21,7 @@ def set_image(self, image): self.native.image = image._impl.native else: self.native.image = None + + def rehint(self): + self.interface.intrinsic.width = at_least(0) + self.interface.intrinsic.height = at_least(0) diff --git a/iOS/src/toga_iOS/widgets/multilinetextinput.py b/iOS/src/toga_iOS/widgets/multilinetextinput.py index 86d501788a..6b7bdb6dc2 100644 --- a/iOS/src/toga_iOS/widgets/multilinetextinput.py +++ b/iOS/src/toga_iOS/widgets/multilinetextinput.py @@ -120,8 +120,8 @@ def get_value(self): 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) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def set_font(self, font): if font: diff --git a/iOS/src/toga_iOS/widgets/navigationview.py b/iOS/src/toga_iOS/widgets/navigationview.py index c223ddbfe9..3884ccad43 100644 --- a/iOS/src/toga_iOS/widgets/navigationview.py +++ b/iOS/src/toga_iOS/widgets/navigationview.py @@ -1,4 +1,5 @@ from rubicon.objc import SEL, objc_method, objc_property +from travertino.size import at_least from toga.interface import NavigationView as NavigationViewInterface from toga_iOS.libs import ( @@ -63,3 +64,7 @@ def push(self, content): def pop(self, content): self._controller.popViewController_animated_(True) + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/iOS/src/toga_iOS/widgets/progressbar.py b/iOS/src/toga_iOS/widgets/progressbar.py index cee69dd614..06da27fa13 100644 --- a/iOS/src/toga_iOS/widgets/progressbar.py +++ b/iOS/src/toga_iOS/widgets/progressbar.py @@ -29,5 +29,5 @@ def set_max(self, value): def rehint(self): fitting_size = self.native.systemLayoutSizeFittingSize_(CGSize(0, 0)) - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = fitting_size.height diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index 27634ed140..ecadc7e343 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -1,4 +1,5 @@ from rubicon.objc import CGSizeMake +from travertino.size import at_least from toga_iOS.libs import ( NSLayoutAttributeBottom, @@ -124,6 +125,9 @@ def rehint(self): if self.interface.content: self.update_content_size() + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + def set_on_scroll(self, on_scroll): self.interface.factory.not_implemented("ScrollContainer.set_on_scroll()") diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index a793cd96d9..b89032a21d 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -2,6 +2,7 @@ from rubicon.objc import objc_method, objc_property, py_from_ns from rubicon.objc.runtime import objc_id +from travertino.size import at_least from toga_iOS.libs import NSURL, NSURLRequest, WKWebView from toga_iOS.widgets.base import Widget @@ -90,3 +91,7 @@ def completion_handler(res: objc_id, error: objc_id) -> None: def invoke_javascript(self, javascript): self.native.evaluateJavaScript(javascript, completionHandler=None) + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/winforms/src/toga_winforms/widgets/datepicker.py b/winforms/src/toga_winforms/widgets/datepicker.py index d0f7bbaaa0..8aaf0c8630 100644 --- a/winforms/src/toga_winforms/widgets/datepicker.py +++ b/winforms/src/toga_winforms/widgets/datepicker.py @@ -41,7 +41,7 @@ def set_max_date(self, value): def rehint(self): # Height of a date input is known and fixed. # Width must be > 200 - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.PreferredSize.Height def set_on_change(self, handler): diff --git a/winforms/src/toga_winforms/widgets/detailedlist.py b/winforms/src/toga_winforms/widgets/detailedlist.py index 9b193cc74f..fb30db0d05 100644 --- a/winforms/src/toga_winforms/widgets/detailedlist.py +++ b/winforms/src/toga_winforms/widgets/detailedlist.py @@ -127,8 +127,8 @@ def scroll_to_row(self, row): self.native.EnsureVisible(row) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def build_item(self, row, index): item = WinForms.ListViewItem(row.title) diff --git a/winforms/src/toga_winforms/widgets/multilinetextinput.py b/winforms/src/toga_winforms/widgets/multilinetextinput.py index ea964ea817..c95629eed1 100644 --- a/winforms/src/toga_winforms/widgets/multilinetextinput.py +++ b/winforms/src/toga_winforms/widgets/multilinetextinput.py @@ -47,8 +47,8 @@ def get_value(self): 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) + 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 diff --git a/winforms/src/toga_winforms/widgets/numberinput.py b/winforms/src/toga_winforms/widgets/numberinput.py index a8a619c393..f57c0e740d 100644 --- a/winforms/src/toga_winforms/widgets/numberinput.py +++ b/winforms/src/toga_winforms/widgets/numberinput.py @@ -52,7 +52,7 @@ def set_font(self, font): self.native.Font = font._impl.native def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.PreferredSize.Height def set_on_change(self, handler): diff --git a/winforms/src/toga_winforms/widgets/progressbar.py b/winforms/src/toga_winforms/widgets/progressbar.py index 64b77a08dc..7266e0cbf8 100644 --- a/winforms/src/toga_winforms/widgets/progressbar.py +++ b/winforms/src/toga_winforms/widgets/progressbar.py @@ -42,5 +42,5 @@ def set_value(self, value): def rehint(self): # Height must be non-zero # Set a sensible min-width - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.PreferredSize.Height diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 3ef268d654..8e2984cc1e 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -158,8 +158,8 @@ def scroll_to_row(self, row): self.native.EnsureVisible(row) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def remove_column(self, accessor): self.native.Columns.RemoveByKey(accessor) diff --git a/winforms/src/toga_winforms/widgets/textinput.py b/winforms/src/toga_winforms/widgets/textinput.py index a2c6c0da8d..ec46e42876 100644 --- a/winforms/src/toga_winforms/widgets/textinput.py +++ b/winforms/src/toga_winforms/widgets/textinput.py @@ -75,7 +75,7 @@ def rehint(self): # Height of a text input is known and fixed. # Width must be > 100 # print("REHINT TextInput", self, self.native.PreferredSize) - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.PreferredSize.Height def set_on_change(self, handler): diff --git a/winforms/src/toga_winforms/widgets/timepicker.py b/winforms/src/toga_winforms/widgets/timepicker.py index 462df0cbc9..71f43b1f58 100644 --- a/winforms/src/toga_winforms/widgets/timepicker.py +++ b/winforms/src/toga_winforms/widgets/timepicker.py @@ -45,7 +45,7 @@ def set_max_time(self, value): def rehint(self): # Height of a date input is known and fixed. # Width must be > 100 - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.PreferredSize.Height def set_on_change(self, handler): diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 6d6c62a20b..fcdb12317f 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -164,5 +164,5 @@ def invoke_javascript(self, javascript): self.native.ExecuteScriptAsync(javascript) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) From d9b7645ea6f0d86cdbd7448519b604c842006764 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 09:53:29 +0800 Subject: [PATCH 08/24] Update iOS probes for base widget tests. --- cocoa/src/toga_cocoa/constraints.py | 37 +++++++++++++++--------- cocoa/tests_backend/widgets/textinput.py | 14 +++++++++ docs/reference/api/widgets/button.rst | 3 ++ docs/reference/api/widgets/switch.rst | 3 ++ iOS/src/toga_iOS/constraints.py | 24 ++++++++++----- iOS/src/toga_iOS/widgets/base.py | 19 ++++++------ iOS/src/toga_iOS/widgets/label.py | 9 +++--- iOS/tests_backend/widgets/base.py | 16 ++++++++++ iOS/tests_backend/widgets/textinput.py | 7 +++++ testbed/tests/widgets/conftest.py | 4 +-- testbed/tests/widgets/properties.py | 8 +++-- testbed/tests/widgets/test_button.py | 7 ++++- testbed/tests/widgets/test_switch.py | 7 ++++- 13 files changed, 117 insertions(+), 41 deletions(-) create mode 100644 cocoa/tests_backend/widgets/textinput.py create mode 100644 iOS/tests_backend/widgets/textinput.py diff --git a/cocoa/src/toga_cocoa/constraints.py b/cocoa/src/toga_cocoa/constraints.py index dcf5b2ac0f..3e51cf762b 100644 --- a/cocoa/src/toga_cocoa/constraints.py +++ b/cocoa/src/toga_cocoa/constraints.py @@ -11,9 +11,10 @@ class Constraints: def __init__(self, widget): """ + A wrapper object storing the constraints required to position a widget + at a precise location in its container. - Args: - widget (:class: toga-cocoa.Widget): The widget that should be constrained. + :param widget: The Widget implementation to be constrained. """ self.widget = widget self._container = None @@ -24,6 +25,16 @@ def __init__(self, widget): self.left_constraint = None self.top_constraint = None + def __del__(self): + if self.width_constraint: + self.width_constraint.release() + if self.height_constraint: + self.height_constraint.release() + if self.left_constraint: + self.left_constraint.release() + if self.top_constraint: + self.top_constraint.release() + @property def container(self): return self._container @@ -31,7 +42,7 @@ def container(self): @container.setter def container(self, value): if value is None and self.container: - # print("Remove constraints for", self.widget, 'in', self.container) + # print(f"Remove constraints for {self.widget} in {self.container}") self.container.native.removeConstraint(self.width_constraint) self.container.native.removeConstraint(self.height_constraint) self.container.native.removeConstraint(self.left_constraint) @@ -39,8 +50,8 @@ def container(self, value): self._container = value else: self._container = value - # print("Add constraints for", self.widget, 'in', self.container, self.widget.interface.layout) - self.left_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # NOQA:E501 + # print(f"Add constraints for {self.widget} in {self.container} {self.widget.interface.layout}) + self.left_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeLeft, NSLayoutRelationEqual, @@ -48,10 +59,10 @@ def container(self, value): NSLayoutAttributeLeft, 1.0, 10, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.left_constraint) - self.top_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # NOQA:E501 + self.top_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeTop, NSLayoutRelationEqual, @@ -59,10 +70,10 @@ def container(self, value): NSLayoutAttributeTop, 1.0, 5, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.top_constraint) - self.width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # NOQA:E501 + self.width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeRight, NSLayoutRelationEqual, @@ -70,10 +81,10 @@ def container(self, value): NSLayoutAttributeLeft, 1.0, 50, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.width_constraint) - self.height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # NOQA:E501 + self.height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeBottom, NSLayoutRelationEqual, @@ -81,12 +92,12 @@ def container(self, value): NSLayoutAttributeTop, 1.0, 30, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.height_constraint) def update(self, x, y, width, height): if self.container: - # print("UPDATE", self.widget, 'in', self.container, 'to', x, y, width, height) + # print(f"UPDATE CONSTRAINTS {self.widget} in {self.container} {width}x{height}@{x},{y}") self.left_constraint.constant = x self.top_constraint.constant = y diff --git a/cocoa/tests_backend/widgets/textinput.py b/cocoa/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..01a0c112f4 --- /dev/null +++ b/cocoa/tests_backend/widgets/textinput.py @@ -0,0 +1,14 @@ +from toga_cocoa.libs import NSTextField, NSTextView + +from .base import SimpleProbe + + +class TextInputProbe(SimpleProbe): + native_class = NSTextField + + def has_focus(self): + # When the NSTextField gets focus, a field editor is created, and that editor + # has the original widget as the delegate. The first responder is the Field Editor. + return isinstance(self.native.window.firstResponder, NSTextView) and ( + self.native.window.firstResponder.delegate == self.native + ) diff --git a/docs/reference/api/widgets/button.rst b/docs/reference/api/widgets/button.rst index bae40326a3..f63e3f27ab 100644 --- a/docs/reference/api/widgets/button.rst +++ b/docs/reference/api/widgets/button.rst @@ -39,6 +39,9 @@ Notes directive will be ignored. The text color is automatically selected by the platform to contrast with the background color of the button. +* On iOS, a Button cannot be given focus. Any call to ``button.focus()`` will be + ignored. + Reference --------- diff --git a/docs/reference/api/widgets/switch.rst b/docs/reference/api/widgets/switch.rst index 6816141191..c4b894b610 100644 --- a/docs/reference/api/widgets/switch.rst +++ b/docs/reference/api/widgets/switch.rst @@ -42,6 +42,9 @@ Notes * On macOS, the text color of the label cannot be set directly; any `color` style directive will be ignored. +* On iOS, a Switch cannot be given focus. Any call to ``switch.focus()`` will + be ignored. + Reference --------- diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index 9f9515efa0..95a7e6c62b 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -25,6 +25,16 @@ def __init__(self, widget): self.left_constraint = None self.top_constraint = None + def __del__(self): + if self.width_constraint: + self.width_constraint.release() + if self.height_constraint: + self.height_constraint.release() + if self.left_constraint: + self.left_constraint.release() + if self.top_constraint: + self.top_constraint.release() + @property def container(self): return self._container @@ -32,7 +42,7 @@ def container(self): @container.setter def container(self, value): if value is None and self.container: - # print("Remove constraints") + # print(f"Remove constraints for {self.widget} in {self.container}") self.container.native.removeConstraint(self.width_constraint) self.container.native.removeConstraint(self.height_constraint) self.container.native.removeConstraint(self.left_constraint) @@ -40,7 +50,7 @@ def container(self, value): self._container = value else: self._container = value - # print("Add constraints for", self.widget, "in", self.container, self.widget.interface.layout) + # print(f"Add constraints for {self.widget} in {self.container} {self.widget.interface.layout}) self.left_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeLeft, @@ -49,7 +59,7 @@ def container(self, value): NSLayoutAttributeLeft, 1.0, 10, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.left_constraint) self.top_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 @@ -60,7 +70,7 @@ def container(self, value): NSLayoutAttributeTop, 1.0, 5, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.top_constraint) self.width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 @@ -71,7 +81,7 @@ def container(self, value): NSLayoutAttributeLeft, 1.0, 50, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.width_constraint) self.height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 @@ -82,12 +92,12 @@ def container(self, value): NSLayoutAttributeTop, 1.0, 30, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.height_constraint) def update(self, x, y, width, height): if self.container: - # print(f"UPDATE CONSTRAINTS {self.widget} {width}x{height}@{x},{y}") + # print(f"UPDATE CONSTRAINTS {self.widget} in {self.container} {width}x{height}@{x},{y}") self.left_constraint.constant = x self.top_constraint.constant = y diff --git a/iOS/src/toga_iOS/widgets/base.py b/iOS/src/toga_iOS/widgets/base.py index 573d709d79..244cbf63d1 100644 --- a/iOS/src/toga_iOS/widgets/base.py +++ b/iOS/src/toga_iOS/widgets/base.py @@ -17,8 +17,9 @@ def __init__(self, interface): self.create() self.interface.style.reapply() + @abstractmethod def create(self): - pass + ... def set_app(self, app): pass @@ -36,15 +37,13 @@ def container(self, container): assert container is None, "Widget already has a container" # Existing container should be removed - self.constraints = None + self.constraints.container = None self._container = None self.native.removeFromSuperview() elif container: # setting container self._container = container self._container.native.addSubview(self.native) - if not self.constraints: - self.add_constraints() self.constraints.container = container for child in self.interface.children: @@ -78,6 +77,7 @@ def set_tab_index(self, tab_index): # APPLICATOR def set_bounds(self, x, y, width, height): + # print("SET BOUNDS", self, x, y, width, height, self.constraints) offset_y = 0 if self.container: offset_y = self.container.viewport.top_offset @@ -89,10 +89,7 @@ def set_alignment(self, alignment): pass def set_hidden(self, hidden): - if self.container: - for view in self.container._impl.subviews: - if view._impl: - view.setHidden(hidden) + self.native.setHidden(hidden) def set_font(self, font): # By default, font can't be changed @@ -112,8 +109,10 @@ def set_background_color_simple(self, value): self.native.backgroundColor = native_color(value) else: try: - self.native.backgroundColor = UIColor.systemBackgroundColor() # iOS 13+ - except AttributeError: + # systemBackgroundColor() was introduced in iOS 13 + # We don't test on iOS 12, so mark the other branch as nocover + self.native.backgroundColor = UIColor.systemBackgroundColor() + except AttributeError: # pragma: no cover self.native.backgroundColor = UIColor.whiteColor # INTERFACE diff --git a/iOS/src/toga_iOS/widgets/label.py b/iOS/src/toga_iOS/widgets/label.py index ad4674bfba..96413bd031 100644 --- a/iOS/src/toga_iOS/widgets/label.py +++ b/iOS/src/toga_iOS/widgets/label.py @@ -53,11 +53,10 @@ def rehint(self): # fit. To avoid this, temporarily relax the width and height constraint # on the widget to "effectively infinite" values; they will be # re-applied as part of the application of the newly hinted layout. - if self.constraints: - if self.constraints.width_constraint: - self.constraints.width_constraint.constant = 100000 - if self.constraints.height_constraint: - self.constraints.height_constraint.constant = 100000 + if self.constraints.width_constraint: + self.constraints.width_constraint.constant = 100000 + if self.constraints.height_constraint: + self.constraints.height_constraint.constant = 100000 fitting_size = self.native.systemLayoutSizeFittingSize(CGSize(0, 0)) # print(f"REHINT label {self} {self.get_text()!r} {fitting_size.width} {fitting_size.height}") self.interface.intrinsic.width = at_least(ceil(fitting_size.width)) diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index 333ea58f92..35bb286fd1 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -48,6 +48,10 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.superview() is None + def assert_alignment(self, expected): assert self.alignment == expected @@ -90,6 +94,15 @@ def width(self): def height(self): return self.native.frame.size.height + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.superview() is not None + + # size and position is as expected. + assert (self.native.frame.size.width, self.native.frame.size.height) == size + assert (self.native.frame.origin.x, self.native.frame.origin.y - 98) == position + def assert_width(self, min_width, max_width): assert ( min_width <= self.width <= max_width @@ -109,3 +122,6 @@ def background_color(self): def press(self): self.native.sendActionsForControlEvents(UIControlEventTouchDown) + + def has_focus(self): + return self.native.isFirstResponder diff --git a/iOS/tests_backend/widgets/textinput.py b/iOS/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..d9efc7e3aa --- /dev/null +++ b/iOS/tests_backend/widgets/textinput.py @@ -0,0 +1,7 @@ +from toga_iOS.libs import UITextField + +from .base import SimpleProbe + + +class TextInputProbe(SimpleProbe): + native_class = UITextField diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index d4c3a39a9f..39e75b7e67 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -12,13 +12,13 @@ async def widget(): @fixture async def probe(main_window, widget): + old_content = main_window.content box = toga.Box(children=[widget]) main_window.content = box - probe = get_probe(widget) await probe.redraw() probe.assert_container(box) yield probe - main_window.content = toga.Box() + main_window.content = old_content diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index a5dd8c6ab3..bd3704c554 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -55,15 +55,17 @@ async def test_enable_noop(widget, probe): async def test_focus(widget, probe): "The widget can be given focus" # Add a separate widget that can take take focus - other = toga.Button("Other") + other = toga.TextInput() widget.parent.add(other) other_probe = get_probe(other) other.focus() + await probe.redraw() assert not probe.has_focus() assert other_probe.has_focus() widget.focus() + await probe.redraw() assert probe.has_focus() assert not other_probe.has_focus() @@ -71,16 +73,18 @@ async def test_focus(widget, probe): async def test_focus_noop(widget, probe): "The widget cannot be given focus" # Add a separate widget that can take take focus - other = toga.Button("Other") + other = toga.TextInput() widget.parent.add(other) other_probe = get_probe(other) other.focus() + await probe.redraw() assert not probe.has_focus() assert other_probe.has_focus() # Widget has *not* taken focus widget.focus() + await probe.redraw() assert not probe.has_focus() assert other_probe.has_focus() diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py index 24914c7f4f..dbc1110bbc 100644 --- a/testbed/tests/widgets/test_button.py +++ b/testbed/tests/widgets/test_button.py @@ -14,12 +14,17 @@ test_color_reset, test_enabled, test_flex_horizontal_widget_size, - test_focus, test_font, test_font_attrs, test_text_width_change, ) +# Buttons can't be given focus on iOS +if toga.platform.current_platform in {"iOS"}: + from .properties import test_focus_noop # noqa: F401 +else: + from .properties import test_focus # noqa: F401 + @fixture async def widget(): diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py index 8b17ced19b..72ef96cfc0 100644 --- a/testbed/tests/widgets/test_switch.py +++ b/testbed/tests/widgets/test_switch.py @@ -10,12 +10,17 @@ test_color_reset, test_enabled, test_flex_horizontal_widget_size, - test_focus, test_font, test_font_attrs, test_text_width_change, ) +# Switches can't be given focus on iOS +if toga.platform.current_platform in {"iOS"}: + from .properties import test_focus_noop # noqa: F401 +else: + from .properties import test_focus # noqa: F401 + @fixture async def widget(): From 2ea9673bdd607fa436e14e82ad994488420c59e9 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 11:44:56 +0800 Subject: [PATCH 09/24] Corrected PR number for widget audit. --- changes/{1844.feature.rst => 1834.feature.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changes/{1844.feature.rst => 1834.feature.rst} (100%) diff --git a/changes/1844.feature.rst b/changes/1834.feature.rst similarity index 100% rename from changes/1844.feature.rst rename to changes/1834.feature.rst From 332ae4185b75dea6e75d0a1fe93168409e925a4d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 12:28:54 +0800 Subject: [PATCH 10/24] Add visibility tests, and move base tests to their own module. --- cocoa/tests_backend/widgets/base.py | 5 ++ cocoa/tests_backend/widgets/textinput.py | 1 + core/src/toga/style/applicator.py | 2 + core/src/toga/style/pack.py | 2 +- iOS/tests_backend/widgets/base.py | 18 +++- testbed/tests/widgets/conftest.py | 1 + testbed/tests/widgets/properties.py | 16 ++-- testbed/tests/widgets/test_base.py | 102 +++++++++++++++++++++++ testbed/tests/widgets/test_box.py | 50 ----------- 9 files changed, 136 insertions(+), 61 deletions(-) create mode 100644 testbed/tests/widgets/test_base.py diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index 3b2dcb0c01..e033a4f98a 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -96,5 +96,10 @@ def background_color(self): def press(self): self.native.performClick(None) + @property + def is_hidden(self): + return self.native.isHidden() + + @property def has_focus(self): return self.native.window.firstResponder == self.native diff --git a/cocoa/tests_backend/widgets/textinput.py b/cocoa/tests_backend/widgets/textinput.py index 01a0c112f4..5be2b0116a 100644 --- a/cocoa/tests_backend/widgets/textinput.py +++ b/cocoa/tests_backend/widgets/textinput.py @@ -6,6 +6,7 @@ class TextInputProbe(SimpleProbe): native_class = NSTextField + @property def has_focus(self): # When the NSTextField gets focus, a field editor is created, and that editor # has the original widget as the delegate. The first responder is the Field Editor. diff --git a/core/src/toga/style/applicator.py b/core/src/toga/style/applicator.py index e12d52fd61..2df5b92619 100644 --- a/core/src/toga/style/applicator.py +++ b/core/src/toga/style/applicator.py @@ -24,6 +24,8 @@ def set_text_alignment(self, alignment): def set_hidden(self, hidden): self.widget._impl.set_hidden(hidden) + for child in self.widget.children: + child.applicator.set_hidden(hidden) def set_font(self, font): # Changing the font of a widget can make the widget change size, diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index 3d498ae05b..d1990ae714 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -42,7 +42,7 @@ ###################################################################### DISPLAY_CHOICES = Choices(PACK, NONE) -VISIBILITY_CHOICES = Choices(VISIBLE, HIDDEN, NONE) +VISIBILITY_CHOICES = Choices(VISIBLE, HIDDEN) DIRECTION_CHOICES = Choices(ROW, COLUMN) ALIGNMENT_CHOICES = Choices(LEFT, RIGHT, TOP, BOTTOM, CENTER, default=True) diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index 35bb286fd1..16782806cb 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -2,7 +2,7 @@ from toga.colors import TRANSPARENT from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM -from toga_iOS.libs import NSRunLoop, UIColor +from toga_iOS.libs import NSRunLoop, UIApplication, UIColor from .properties import toga_color @@ -101,7 +101,16 @@ def assert_layout(self, size, position): # size and position is as expected. assert (self.native.frame.size.width, self.native.frame.size.height) == size - assert (self.native.frame.origin.x, self.native.frame.origin.y - 98) == position + + # Allow for the status bar and navigation bar in vertical position + statusbar_frame = UIApplication.sharedApplication.statusBarFrame + navbar = self.widget.window._impl.controller.navigationController + navbar_frame = navbar.navigationBar.frame + offset = statusbar_frame.size.height + navbar_frame.size.height + assert ( + self.native.frame.origin.x, + self.native.frame.origin.y - offset, + ) == position def assert_width(self, min_width, max_width): assert ( @@ -123,5 +132,10 @@ def background_color(self): def press(self): self.native.sendActionsForControlEvents(UIControlEventTouchDown) + @property + def is_hidden(self): + return self.native.isHidden() + + @property def has_focus(self): return self.native.isFirstResponder diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index 39e75b7e67..b29c575fd3 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -17,6 +17,7 @@ async def probe(main_window, widget): main_window.content = box probe = get_probe(widget) await probe.redraw() + probe.assert_container(box) yield probe diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index bd3704c554..81d9ca2ad5 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -61,13 +61,13 @@ async def test_focus(widget, probe): other.focus() await probe.redraw() - assert not probe.has_focus() - assert other_probe.has_focus() + assert not probe.has_focus + assert other_probe.has_focus widget.focus() await probe.redraw() - assert probe.has_focus() - assert not other_probe.has_focus() + assert probe.has_focus + assert not other_probe.has_focus async def test_focus_noop(widget, probe): @@ -79,14 +79,14 @@ async def test_focus_noop(widget, probe): other.focus() await probe.redraw() - assert not probe.has_focus() - assert other_probe.has_focus() + assert not probe.has_focus + assert other_probe.has_focus # Widget has *not* taken focus widget.focus() await probe.redraw() - assert not probe.has_focus() - assert other_probe.has_focus() + assert not probe.has_focus + assert other_probe.has_focus async def test_text(widget, probe): diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py new file mode 100644 index 0000000000..42abe0b5e1 --- /dev/null +++ b/testbed/tests/widgets/test_base.py @@ -0,0 +1,102 @@ +import pytest + +import toga +from toga.colors import BLACK, BLUE, GREEN, RED +from toga.style import Pack +from toga.style.pack import HIDDEN, VISIBLE + +from .probe import get_probe + + +@pytest.fixture +async def widget(): + return toga.Box(style=Pack(width=100, height=200, background_color=RED)) + + +async def test_visibility(widget, probe): + "A widget (and it's children) can be made invisible" + child = toga.Button("Hello") + child_probe = get_probe(child) + + other = toga.Box(style=Pack(width=100, height=200, background_color=BLUE)) + other_probe = get_probe(other) + + widget.parent.add(other) + widget.add(child) + + await probe.redraw() + + # Widgets are all visible an in place + assert not probe.is_hidden + assert not child_probe.is_hidden + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + + # Hide the widget + widget.style.visibility = HIDDEN + await probe.redraw() + + # Widget and child are no longer visible. + assert probe.is_hidden + assert child_probe.is_hidden + # Making the widget invisible doesn't affect layout + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + + # Make widget visible again + widget.style.visibility = VISIBLE + await probe.redraw() + + # Widgets and child are visible and in place again. + assert not probe.is_hidden + assert not child_probe.is_hidden + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + + +async def test_parenting(widget, probe): + "A widget can be reparented between containers" + box = widget.parent + + child = toga.Box(style=Pack(width=50, height=75, background_color=GREEN)) + child_probe = get_probe(child) + other = toga.Box(style=Pack(width=100, height=200, background_color=BLUE)) + other_probe = get_probe(other) + other_child = toga.Box(style=Pack(width=25, height=50, background_color=BLACK)) + other.add(other_child) + + # Layout has the test widget, plus other, horizontally laid out. + # Child isn't in the layout yet. + box.add(other) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_not_contained() + + # Add child to widget. + widget.add(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_layout(position=(0, 0), size=(50, 75)) + + # Reparent child to other without removing first + other.add(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_layout(position=(125, 0), size=(50, 75)) + + # Remove child from the layout entirely + other.remove(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_not_contained() + + # Insert the child into the root layout + box.insert(1, child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(150, 0), size=(100, 200)) + child_probe.assert_layout(position=(100, 0), size=(50, 75)) diff --git a/testbed/tests/widgets/test_box.py b/testbed/tests/widgets/test_box.py index 02858c1b63..0959a27db8 100644 --- a/testbed/tests/widgets/test_box.py +++ b/testbed/tests/widgets/test_box.py @@ -1,10 +1,8 @@ import pytest import toga -from toga.colors import BLACK, BLUE, GREEN, RED from toga.style import Pack -from .probe import get_probe from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, @@ -17,51 +15,3 @@ @pytest.fixture async def widget(): return toga.Box(style=Pack(width=100, height=200)) - - -async def test_parenting(widget, probe): - widget.style.background_color = RED - box = widget.parent - - child = toga.Box(style=Pack(width=50, height=75, background_color=GREEN)) - child_probe = get_probe(child) - other = toga.Box(style=Pack(width=100, height=200, background_color=BLUE)) - other_probe = get_probe(other) - other_child = toga.Box(style=Pack(width=25, height=50, background_color=BLACK)) - other.add(other_child) - - # Layout has the test widget, plus other, horizontally laid out. - # Child isn't in the layout yet. - box.add(other) - await probe.redraw() - probe.assert_layout(position=(0, 0), size=(100, 200)) - other_probe.assert_layout(position=(100, 0), size=(100, 200)) - child_probe.assert_not_contained() - - # Add child to widget. - widget.add(child) - await probe.redraw() - probe.assert_layout(position=(0, 0), size=(100, 200)) - other_probe.assert_layout(position=(100, 0), size=(100, 200)) - child_probe.assert_layout(position=(0, 0), size=(50, 75)) - - # Reparent child to other without removing first - other.add(child) - await probe.redraw() - probe.assert_layout(position=(0, 0), size=(100, 200)) - other_probe.assert_layout(position=(100, 0), size=(100, 200)) - child_probe.assert_layout(position=(125, 0), size=(50, 75)) - - # Remove child from the layout entirely - other.remove(child) - await probe.redraw() - probe.assert_layout(position=(0, 0), size=(100, 200)) - other_probe.assert_layout(position=(100, 0), size=(100, 200)) - child_probe.assert_not_contained() - - # Insert the child into the root layout - box.insert(1, child) - await probe.redraw() - probe.assert_layout(position=(0, 0), size=(100, 200)) - other_probe.assert_layout(position=(150, 0), size=(100, 200)) - child_probe.assert_layout(position=(100, 0), size=(50, 75)) From 20eedf4e564e5dba58c1872821fcfee66ad996d7 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 13:21:05 +0800 Subject: [PATCH 11/24] Added an Android probe. --- android/src/toga_android/widgets/base.py | 40 ++++++++++------------ android/src/toga_android/widgets/canvas.py | 3 ++ android/tests_backend/widgets/base.py | 34 +++++++++++++++++- android/tests_backend/widgets/textinput.py | 8 +++++ docs/reference/api/widgets/button.rst | 4 +-- docs/reference/api/widgets/switch.rst | 4 +-- testbed/tests/testbed.py | 1 - testbed/tests/widgets/test_button.py | 4 +-- testbed/tests/widgets/test_switch.py | 4 +-- 9 files changed, 71 insertions(+), 31 deletions(-) create mode 100644 android/tests_backend/widgets/textinput.py diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 7c6d13520c..8167866a16 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from toga.constants import CENTER, JUSTIFY, LEFT, RIGHT, TRANSPARENT from ..colors import native_color @@ -16,7 +18,8 @@ def _get_activity(_cache=[]): return _cache[0] # See MainActivity.onCreate() for initialization of .singletonThis: # https://github.com/beeware/briefcase-android-gradle-template/blob/3.7/%7B%7B%20cookiecutter.formal_name%20%7D%7D/app/src/main/java/org/beeware/android/MainActivity.java - if not MainActivity.singletonThis: + # This can't be tested because if it isn't set, nothing else will work. + if not MainActivity.singletonThis: # pragma: no cover raise ValueError( "Unable to find MainActivity.singletonThis from Python. This is typically set by " "org.beeware.android.MainActivity.onCreate()." @@ -38,8 +41,9 @@ def __init__(self, interface): # they have been added to a container. self.interface.style.reapply() + @abstractmethod def create(self): - pass + ... def set_app(self, app): pass @@ -54,20 +58,18 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # container is set to None, removing self from the container.native - self._container.native.removeView(self.native) - self._container.native.invalidate() - self._container = None + assert container is None, "Widget already has a container" + + # container is set to None, removing self from the container.native + self._container.native.removeView(self.native) + self._container.native.invalidate() + self._container = None elif container: self._container = container - if self.native: - # When initially setting the container and adding widgets to the container, - # we provide no `LayoutParams`. Those are promptly added when Toga - # calls `widget.rehint()` and `widget.set_bounds()`. - self._container.native.addView(self.native) + # When initially setting the container and adding widgets to the container, + # we provide no `LayoutParams`. Those are promptly added when Toga + # calls `widget.rehint()` and `widget.set_bounds()`. + self._container.native.addView(self.native) for child in self.interface.children: child._impl.container = container @@ -84,10 +86,10 @@ def focus(self): self.native.requestFocus() def get_tab_index(self): - self.interface.factory.not_implementated("Widget.get_tab_index()") + self.interface.factory.not_implemented("Widget.get_tab_index()") def set_tab_index(self, tab_index): - self.interface.factory.not_implementated("Widget.set_tab_index()") + self.interface.factory.not_implemented("Widget.set_tab_index()") # APPLICATOR @@ -97,11 +99,6 @@ def set_bounds(self, x, y, width, height): self.container.set_child_bounds(self, x, y, width, height) def set_hidden(self, hidden): - view = View(self._native_activity) - if not view.getClass().isInstance(self.native): - # save guard for Widgets like Canvas that are not based on View - self.interface.factory.not_implemented("Widget.set_hidden()") - return if hidden: self.native.setVisibility(View.INVISIBLE) else: @@ -148,6 +145,7 @@ def remove_child(self, child): def refresh(self): self.rehint() + @abstractmethod def rehint(self): pass diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index c75fb8a44d..81e983bdf6 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -31,6 +31,9 @@ def create(self): ) self.native.setDrawHandler(DrawHandler(self.interface)) + def set_hidden(self, hidden): + self.interface.factory.not_implemented("Canvas.set_hidden()") + def redraw(self): pass diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index a6de2b4f32..3d657d526f 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -1,8 +1,9 @@ import asyncio from java import dynamic_proxy +from pytest import approx -from android.view import ViewTreeObserver +from android.view import View, ViewTreeObserver from toga.fonts import SYSTEM from .properties import toga_color @@ -49,6 +50,10 @@ def assert_container(self, container): else: raise AssertionError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.getParent() is None + def assert_alignment(self, expected): assert self.alignment == expected @@ -92,9 +97,36 @@ def assert_height(self, min_height, max_height): min_height <= self.height <= max_height ), f"Height ({self.height}) not in range ({min_height}, {max_height})" + def assert_layout(self, size, position): + # Widget is contained + assert self.widget._impl.container is not None + assert self.native.getParent() is not None + + # Size and position is as expected. Values must be scaled from DP, and + # compared inexactly due to pixel scaling + assert ( + approx(self.native.getWidth() / self.scale_factor, rel=0.01), + approx(self.native.getHeight() / self.scale_factor, rel=0.01), + ) == size + assert ( + approx(self.native.getLeft() / self.scale_factor, rel=0.01), + approx(self.native.getTop() / self.scale_factor, rel=0.01), + ) == position + @property def background_color(self): return toga_color(self.native.getBackground().getColor()) def press(self): self.native.performClick() + + @property + def is_hidden(self): + return self.native.getVisibility() == View.INVISIBLE + + @property + def has_focus(self): + print( + f"NATIVE {self.native} FOCUS {self.widget.app._impl.native.getCurrentFocus()}" + ) + return self.widget.app._impl.native.getCurrentFocus() == self.native diff --git a/android/tests_backend/widgets/textinput.py b/android/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..88d22e19dd --- /dev/null +++ b/android/tests_backend/widgets/textinput.py @@ -0,0 +1,8 @@ +from java import jclass + +from .label import LabelProbe + + +# On Android, a Button is just a TextView with a state-dependent background image. +class TextInputProbe(LabelProbe): + native_class = jclass("android.widget.EditText") diff --git a/docs/reference/api/widgets/button.rst b/docs/reference/api/widgets/button.rst index f63e3f27ab..ef3a912104 100644 --- a/docs/reference/api/widgets/button.rst +++ b/docs/reference/api/widgets/button.rst @@ -39,8 +39,8 @@ Notes directive will be ignored. The text color is automatically selected by the platform to contrast with the background color of the button. -* On iOS, a Button cannot be given focus. Any call to ``button.focus()`` will be - ignored. +* On mobile platforms, a Button cannot be given focus. Any call to + ``button.focus()`` will be ignored. Reference --------- diff --git a/docs/reference/api/widgets/switch.rst b/docs/reference/api/widgets/switch.rst index c4b894b610..0abcd203ee 100644 --- a/docs/reference/api/widgets/switch.rst +++ b/docs/reference/api/widgets/switch.rst @@ -42,8 +42,8 @@ Notes * On macOS, the text color of the label cannot be set directly; any `color` style directive will be ignored. -* On iOS, a Switch cannot be given focus. Any call to ``switch.focus()`` will - be ignored. +* On mobile platforms, a Switch cannot be given focus. Any call to + ``switch.focus()`` will be ignored. Reference --------- diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 98e8ecb0fa..ffbea6896d 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -26,7 +26,6 @@ def run_tests(app, cov, args, report_coverage, run_slow): "-vv", "--no-header", "--tb=native", - "-rP", # Show stdout from all tests, even if they passed. "--color=no", # Run all async tests and fixtures using pytest-asyncio. "--asyncio-mode=auto", diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py index dbc1110bbc..e0e661dbfd 100644 --- a/testbed/tests/widgets/test_button.py +++ b/testbed/tests/widgets/test_button.py @@ -19,8 +19,8 @@ test_text_width_change, ) -# Buttons can't be given focus on iOS -if toga.platform.current_platform in {"iOS"}: +# Buttons can't be given focus on mobile +if toga.platform.current_platform in {"android", "iOS"}: from .properties import test_focus_noop # noqa: F401 else: from .properties import test_focus # noqa: F401 diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py index 72ef96cfc0..87a60fc7c3 100644 --- a/testbed/tests/widgets/test_switch.py +++ b/testbed/tests/widgets/test_switch.py @@ -15,8 +15,8 @@ test_text_width_change, ) -# Switches can't be given focus on iOS -if toga.platform.current_platform in {"iOS"}: +# Switches can't be given focus on mobile +if toga.platform.current_platform in {"android", "iOS"}: from .properties import test_focus_noop # noqa: F401 else: from .properties import test_focus # noqa: F401 From 8a8423233a6742a346a8d954a818d9eb2dd77be0 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 13:28:04 +0800 Subject: [PATCH 12/24] Removed a call to a deprecated GTK API. --- changes/1718.bugfix.rst | 1 + gtk/src/toga_gtk/app.py | 11 ++--------- 2 files changed, 3 insertions(+), 9 deletions(-) create mode 100644 changes/1718.bugfix.rst diff --git a/changes/1718.bugfix.rst b/changes/1718.bugfix.rst new file mode 100644 index 0000000000..7468ca785b --- /dev/null +++ b/changes/1718.bugfix.rst @@ -0,0 +1 @@ +The usage of the deprecated ``set_wmclass`` API by the GTK backend has been removed. diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 5a8091cf89..90ba5d4b1a 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -34,14 +34,6 @@ def create(self): icon_impl = toga_App.app.icon._impl self.native.set_icon(icon_impl.native_72.get_pixbuf()) - def set_app(self, app): - super().set_app(app) - - # The GTK docs list set_wmclass() as deprecated (and "pointless") - # but it's the only way I've found that actually sets the - # Application name to something other than '__main__.py'. - self.native.set_wmclass(app.interface.name, app.interface.name) - def gtk_delete_event(self, *args): # Return value of the GTK on_close handler indicates # whether the event has been fully handled. Returning @@ -73,7 +65,8 @@ def __init__(self, interface): def create(self): # Stimulate the build of the app self.native = Gtk.Application( - application_id=self.interface.app_id, flags=Gio.ApplicationFlags.FLAGS_NONE + application_id=self.interface.app_id, + flags=Gio.ApplicationFlags.FLAGS_NONE, ) # Connect the GTK signal that will cause app startup to occur From 95666581c68b46419be9d07eba2ffc1feb079031 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 14:08:07 +0800 Subject: [PATCH 13/24] Add GTK probe implementation for base. --- docs/reference/api/widgets/switch.rst | 2 +- gtk/src/toga_gtk/widgets/base.py | 35 +++++++++++++------------- gtk/tests_backend/widgets/base.py | 30 ++++++++++++++++++++++ gtk/tests_backend/widgets/textinput.py | 7 ++++++ testbed/tests/widgets/test_switch.py | 4 +-- 5 files changed, 58 insertions(+), 20 deletions(-) create mode 100644 gtk/tests_backend/widgets/textinput.py diff --git a/docs/reference/api/widgets/switch.rst b/docs/reference/api/widgets/switch.rst index 0abcd203ee..606124afed 100644 --- a/docs/reference/api/widgets/switch.rst +++ b/docs/reference/api/widgets/switch.rst @@ -42,7 +42,7 @@ Notes * On macOS, the text color of the label cannot be set directly; any `color` style directive will be ignored. -* On mobile platforms, a Switch cannot be given focus. Any call to +* On mobile platforms, and on GTK, a Switch cannot be given focus. Any call to ``switch.focus()`` will be ignored. Reference diff --git a/gtk/src/toga_gtk/widgets/base.py b/gtk/src/toga_gtk/widgets/base.py index 09e19e3075..30cd60480c 100644 --- a/gtk/src/toga_gtk/widgets/base.py +++ b/gtk/src/toga_gtk/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from travertino.size import at_least from ..libs import Gtk, get_background_color_css, get_color_css, get_font_css @@ -29,8 +31,9 @@ def viewport(self): # TODO: Remove the use of viewport return self._container + @abstractmethod def create(self): - pass + ... def set_app(self, app): pass @@ -45,17 +48,16 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # container is set to None, removing self from the container.native - # Note from pygtk documentation: Note that the container will own a - # reference to widget, and that this may be the last reference held; - # so removing a widget from its container can cause that widget to be - # destroyed. If you want to use widget again, you should add a - # reference to it. - self._container.remove(self.native) - self._container = None + assert container is None, "Widget Already have a container" + + # container is set to None, removing self from the container.native + # Note from pygtk documentation: Note that the container will own a + # reference to widget, and that this may be the last reference held; + # so removing a widget from its container can cause that widget to be + # destroyed. If you want to use widget again, you should add a + # reference to it. + self._container.remove(self.native) + self._container = None elif container: # setting container, adding self to container.native self._container = container @@ -77,10 +79,10 @@ def focus(self): self.native.grab_focus() def get_tab_index(self): - self.interface.factory.not_implementated("Widget.get_tab_index()") + self.interface.factory.not_implemented("Widget.get_tab_index()") def set_tab_index(self, tab_index): - self.interface.factory.not_implementated("Widget.set_tab_index()") + self.interface.factory.not_implemented("Widget.set_tab_index()") ###################################################################### # CSS tools @@ -141,15 +143,14 @@ def apply_css(self, property, css, native=None): def set_bounds(self, x, y, width, height): # Any position changes are applied by the container during do_size_allocate. - if self.container: - self.container.make_dirty() + self.container.make_dirty() def set_alignment(self, alignment): # By default, alignment can't be changed pass def set_hidden(self, hidden): - return not self.native.set_visible(not hidden) + self.native.set_visible(not hidden) def set_color(self, color): self.apply_css("color", get_color_css(color)) diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 1ca10d9a0b..e21837a382 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -24,6 +24,10 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.get_parent() is None + def assert_alignment(self, expected): assert self.alignment == expected @@ -52,6 +56,24 @@ def width(self): def height(self): return self.native.get_allocation().height + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.get_parent() is not None + + # Measurements are relative to the container as an origin. + origin = self.widget._impl.container.get_allocation() + + # size and position is as expected. + assert ( + self.native.get_allocation().width, + self.native.get_allocation().height, + ) == size + assert ( + self.native.get_allocation().x - origin.x, + self.native.get_allocation().y - origin.y, + ) == position + def assert_width(self, min_width, max_width): assert ( min_width <= self.width <= max_width @@ -76,3 +98,11 @@ def background_color(self): def font(self): sc = self.native.get_style_context() return toga_font(sc.get_property("font", sc.get_state())) + + @property + def is_hidden(self): + return not self.native.get_visible() + + @property + def has_focus(self): + return self.native.has_focus() diff --git a/gtk/tests_backend/widgets/textinput.py b/gtk/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..d2a347fe29 --- /dev/null +++ b/gtk/tests_backend/widgets/textinput.py @@ -0,0 +1,7 @@ +from toga_gtk.libs import Gtk + +from .base import SimpleProbe + + +class TextInputProbe(SimpleProbe): + native_class = Gtk.Entry diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py index 87a60fc7c3..cd14fed489 100644 --- a/testbed/tests/widgets/test_switch.py +++ b/testbed/tests/widgets/test_switch.py @@ -15,8 +15,8 @@ test_text_width_change, ) -# Switches can't be given focus on mobile -if toga.platform.current_platform in {"android", "iOS"}: +# Switches can't be given focus on mobile, or on GTK +if toga.platform.current_platform in {"android", "iOS", "linux"}: from .properties import test_focus_noop # noqa: F401 else: from .properties import test_focus # noqa: F401 From e605385f7151e7d1c1b06b46b45ec3b3285e93ec Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 14:31:12 +0800 Subject: [PATCH 14/24] Add winforms probe for base widget. --- winforms/src/toga_winforms/widgets/base.py | 47 +++++++++++---------- winforms/tests_backend/widgets/base.py | 24 +++++++++++ winforms/tests_backend/widgets/textinput.py | 7 +++ 3 files changed, 55 insertions(+), 23 deletions(-) create mode 100644 winforms/tests_backend/widgets/textinput.py diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 9d43568c06..be2a4ed86e 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from toga_winforms.colors import native_color from toga_winforms.libs import Point, Size, SystemColors @@ -13,8 +15,9 @@ def __init__(self, interface): self.create() self.interface.style.reapply() + @abstractmethod def create(self): - pass + ... def set_app(self, app): # No special handling required @@ -31,12 +34,10 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # container is set to None, removing self from the container.native - self._container.native.Controls.Remove(self.native) - self._container = None + assert container is None, "Widget already has a container" + # container is set to None, removing self from the container.native + self._container.native.Controls.Remove(self.native) + self._container = None elif container: # setting container, adding self to container.native self._container = container @@ -69,8 +70,7 @@ def set_enabled(self, value): self.native.Enabled = value def focus(self): - if self.native: - self.native.Focus() + self.native.Focus() # APPLICATOR @@ -79,24 +79,22 @@ def vertical_shift(self): return 0 def set_bounds(self, x, y, width, height): - if self.native: - # Root level widgets may require vertical adjustment to - # account for toolbars, etc. - if self.interface.parent is None: - vertical_shift = self.frame.vertical_shift - else: - vertical_shift = 0 + # Root level widgets may require vertical adjustment to + # account for toolbars, etc. + if self.interface.parent is None: + vertical_shift = self.frame.vertical_shift + else: + vertical_shift = 0 - self.native.Size = Size(width, height) - self.native.Location = Point(x, y + vertical_shift) + self.native.Size = Size(width, height) + self.native.Location = Point(x, y + vertical_shift) def set_alignment(self, alignment): # By default, alignment can't be changed pass def set_hidden(self, hidden): - if self.native: - self.native.Visible = not hidden + self.native.Visible = not hidden def set_font(self, font): # By default, font can't be changed @@ -124,9 +122,11 @@ def add_child(self, child): child.container = self.container def insert_child(self, index, child): - if self.container: + if self.viewport: + # we are the the top level container + child.container = self + else: child.container = self.container - self.container.native.Controls.SetChildIndex(child.native, index) def remove_child(self, child): child.container = None @@ -134,5 +134,6 @@ def remove_child(self, child): def refresh(self): self.rehint() + @abstractmethod def rehint(self): - pass + ... diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 6fe6f3bbf6..19f5d5e0a4 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -24,6 +24,10 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.Parent is None + def assert_alignment(self, expected): # Winforms doesn't have a "Justified" alignment; it falls back to LEFT actual = self.alignment @@ -94,5 +98,25 @@ def assert_height(self, min_height, max_height): min_height <= self.height <= max_height ), f"Height ({self.height}) not in range ({min_height}, {max_height})" + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.Parent is not None + + # size and position is as expected. + assert (self.native.Width, self.native.Height) == size + assert ( + self.native.Left, + self.native.Top - self.widget._impl.container.vertical_shift, + ) == position + def press(self): self.native.OnClick(EventArgs.Empty) + + @property + def is_hidden(self): + return not self.native.Visible + + @property + def has_focus(self): + return self.native.ContainsFocus diff --git a/winforms/tests_backend/widgets/textinput.py b/winforms/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..df30717e5b --- /dev/null +++ b/winforms/tests_backend/widgets/textinput.py @@ -0,0 +1,7 @@ +import System.Windows.Forms + +from .base import SimpleProbe + + +class TextInputProbe(SimpleProbe): + native_class = System.Windows.Forms.TextBox From 0194f688c65f507911c059c706e88ca3f0b0b40f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 14:31:44 +0800 Subject: [PATCH 15/24] Update widget support chart. --- docs/reference/data/widgets_by_platform.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index a7185f2c91..43caf14f90 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -22,7 +22,7 @@ TextInput,General Widget,:class:`~toga.widgets.textinput.TextInput`,Text Input f TimePicker,General Widget,:class:`~toga.widgets.timepicker.TimePicker`,An input for times,,,|b|,,|b|, Tree,General Widget,:class:`~toga.widgets.tree.Tree`,Tree of data,|b|,|b|,|b|,,, WebView,General Widget,:class:`~toga.widgets.webview.WebView`,A panel for displaying HTML,|b|,|b|,|b|,|b|,|b|, -Widget,General Widget,:class:`~toga.widgets.base.Widget`,The base widget,|b|,|b|,|b|,|b|,|b|,|b| +Widget,General Widget,:class:`~toga.widgets.base.Widget`,The base widget,|y|,|y|,|y|,|y|,|y|,|b| Box,Layout Widget,:class:`~toga.widgets.box.Box`,Container for components,|y|,|y|,|y|,|y|,|y|,|b| ScrollContainer,Layout Widget,:class:`~toga.widgets.scrollcontainer.ScrollContainer`,Scrollable Container,|b|,|b|,|b|,|b|,|b|, SplitContainer,Layout Widget,:class:`~toga.widgets.splitcontainer.SplitContainer`,Split Container,|b|,|b|,|b|,,, From 88552e882e5a0ccb925f857403fe859fb057950c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 14:37:54 +0800 Subject: [PATCH 16/24] Mark tab_index as a beta feature. --- core/src/toga/widgets/base.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index a3c3334c93..5a3af04d67 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -76,7 +76,13 @@ def id(self): @property def tab_index(self): - """The position of the widget in the focus chain for the window.""" + """The position of the widget in the focus chain for the window. + + .. note:: + + This is a beta feature. The ``tab_index`` API may change in + future. + """ return self._impl.get_tab_index() @tab_index.setter From 43a4fd9e2806dfad94149c31fabe3b2b7a04a419 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 4 Apr 2023 14:51:02 +0800 Subject: [PATCH 17/24] Skip focus probe on GTK due to XVFB issues. --- gtk/tests_backend/widgets/base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index e21837a382..e298a8b6a1 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -1,5 +1,7 @@ import asyncio +import pytest + from toga_gtk.libs import Gtk from .properties import toga_color, toga_font @@ -105,4 +107,7 @@ def is_hidden(self): @property def has_focus(self): - return self.native.has_focus() + # FIXME: This works when running standalone, but fails under CI. + # I *think* this is because CI is using xvfb. + # return self.native.has_focus() + pytest.skip("Focus changes don't work on GTK inside XVFB") From 18c1c776b125b665dca891fd6c2d1a39f6991d71 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 10 Apr 2023 09:27:36 +0800 Subject: [PATCH 18/24] Tweaks arising from code review. --- android/tests_backend/widgets/base.py | 3 --- android/tests_backend/widgets/textinput.py | 2 +- cocoa/src/toga_cocoa/widgets/base.py | 3 ++- core/src/toga/widgets/base.py | 21 ++++++++++++++------- core/src/toga/widgets/box.py | 2 +- core/tests/style/test_applicator.py | 8 ++++++-- docs/reference/api/widgets/button.rst | 3 --- docs/reference/api/widgets/switch.rst | 3 --- docs/reference/style/pack.rst | 7 ++++++- testbed/tests/widgets/test_slider.py | 2 +- 10 files changed, 31 insertions(+), 23 deletions(-) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 82465593d5..cc561f656e 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -126,7 +126,4 @@ def is_hidden(self): @property def has_focus(self): - print( - f"NATIVE {self.native} FOCUS {self.widget.app._impl.native.getCurrentFocus()}" - ) return self.widget.app._impl.native.getCurrentFocus() == self.native diff --git a/android/tests_backend/widgets/textinput.py b/android/tests_backend/widgets/textinput.py index 88d22e19dd..8e3b62c969 100644 --- a/android/tests_backend/widgets/textinput.py +++ b/android/tests_backend/widgets/textinput.py @@ -3,6 +3,6 @@ from .label import LabelProbe -# On Android, a Button is just a TextView with a state-dependent background image. +# On Android, a TextInput is just an editable TextView class TextInputProbe(LabelProbe): native_class = jclass("android.widget.EditText") diff --git a/cocoa/src/toga_cocoa/widgets/base.py b/cocoa/src/toga_cocoa/widgets/base.py index 22424c6ffa..442c2b488e 100644 --- a/cocoa/src/toga_cocoa/widgets/base.py +++ b/cocoa/src/toga_cocoa/widgets/base.py @@ -17,8 +17,9 @@ def __init__(self, interface): self.create() self.interface.style.reapply() + @abstractmethod def create(self): - raise NotImplementedError() + ... def set_app(self, app): pass diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 5a3af04d67..beff5850f9 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -92,9 +92,9 @@ def tab_index(self, tab_index): def add(self, *children): """Add the provided widgets as children of this widget. - If a child widget already has parent, it will be re-parented as a child - of this widget. If the child widget is already a child of this widget, - there is no change. + If a child widget already has a parent, it will be re-parented as a + child of this widget. If the child widget is already a child of this + widget, there is no change. Raises ``ValueError`` if this widget cannot have children. @@ -121,9 +121,9 @@ def add(self, *children): def insert(self, index, child): """Insert a widget as a child of this widget. - If a child widget already has parent, it will be re-parented as a child - of this widget. If the child widget is already a child of this widget, - there is no change. + If a child widget already has a parent, it will be re-parented as a + child of this widget. If the child widget is already a child of this + widget, there is no change. Raises ``ValueError`` if this node cannot have children. @@ -256,5 +256,12 @@ def refresh_sublayouts(self): child.refresh_sublayouts() def focus(self): - """Give this widget the input focus.""" + """Give this widget the input focus. + + This method is a no-op if the widget can't accept focus. The ability of + a widget to accept focus is platform-dependent; in general, widgets on + mobile platforms only accept focus if they require keyboard input. On + desktop platforms, focus isn't directly tied to keyboard input, but + there are still some widgets that can't accept focus. + """ self._impl.focus() diff --git a/core/src/toga/widgets/box.py b/core/src/toga/widgets/box.py index b46a1ee3fa..9f07449ca4 100644 --- a/core/src/toga/widgets/box.py +++ b/core/src/toga/widgets/box.py @@ -42,5 +42,5 @@ def enabled(self, value): pass def focus(self): - "No-op; ActivityIndicator cannot accept input focus" + """No-op; Box cannot accept input focus""" pass diff --git a/core/tests/style/test_applicator.py b/core/tests/style/test_applicator.py index 601b7f62a6..288198f045 100644 --- a/core/tests/style/test_applicator.py +++ b/core/tests/style/test_applicator.py @@ -3,7 +3,7 @@ import toga from toga.colors import REBECCAPURPLE from toga.fonts import FANTASY -from toga.style.pack import RIGHT +from toga.style.pack import RIGHT, VISIBLE from toga_dummy.utils import assert_action_performed_with @@ -72,11 +72,15 @@ def test_text_alignment(widget): assert_action_performed_with(widget, "set alignment", alignment=RIGHT) -def test_set_hidden(widget): +def test_set_hidden(widget, child): "Visibility can be set on a widget" widget.applicator.set_hidden(True) assert_action_performed_with(widget, "set hidden", hidden=True) + # The hide is applied transitively to the child + assert_action_performed_with(child, "set hidden", hidden=True) + # However, the style property of the child hasn't changed. + assert child.style.visibility == VISIBLE def test_set_font(widget): diff --git a/docs/reference/api/widgets/button.rst b/docs/reference/api/widgets/button.rst index ef3a912104..bae40326a3 100644 --- a/docs/reference/api/widgets/button.rst +++ b/docs/reference/api/widgets/button.rst @@ -39,9 +39,6 @@ Notes directive will be ignored. The text color is automatically selected by the platform to contrast with the background color of the button. -* On mobile platforms, a Button cannot be given focus. Any call to - ``button.focus()`` will be ignored. - Reference --------- diff --git a/docs/reference/api/widgets/switch.rst b/docs/reference/api/widgets/switch.rst index 606124afed..6816141191 100644 --- a/docs/reference/api/widgets/switch.rst +++ b/docs/reference/api/widgets/switch.rst @@ -42,9 +42,6 @@ Notes * On macOS, the text color of the label cannot be set directly; any `color` style directive will be ignored. -* On mobile platforms, and on GTK, a Switch cannot be given focus. Any call to - ``switch.focus()`` will be ignored. - Reference --------- diff --git a/docs/reference/style/pack.rst b/docs/reference/style/pack.rst index f87e159d37..979ebf0daa 100644 --- a/docs/reference/style/pack.rst +++ b/docs/reference/style/pack.rst @@ -38,7 +38,7 @@ visible. ``visibility`` -------------- -**Values:** ``hidden`` | ``visible`` | ``none`` +**Values:** ``hidden`` | ``visible`` **Initial value:** ``visible`` @@ -46,6 +46,11 @@ Used to define whether the element should be drawn. A value of ``visible`` means the element will be displayed. A value of ``none`` removes the element, but still allocates space for the element as if it were in the element tree. +If an element with children is hidden, all it's children will be implicitly +removed from view. However, if one of those children is to a parent that is +*not* hidden, it will become visible again unless that widget has been +*explicitly* marked as `hidden`. + ``direction`` ------------- diff --git a/testbed/tests/widgets/test_slider.py b/testbed/tests/widgets/test_slider.py index 9557448b9c..e98f5d737f 100644 --- a/testbed/tests/widgets/test_slider.py +++ b/testbed/tests/widgets/test_slider.py @@ -12,7 +12,7 @@ test_flex_horizontal_widget_size, ) -# Buttons can't be given focus on mobile +# Slider can't be given focus on mobile if toga.platform.current_platform in {"android", "iOS"}: from .properties import test_focus_noop # noqa: F401 else: From 85f6de4976edc3b13ca16ad4b2e39369a849e307 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 10 Apr 2023 09:42:02 +0800 Subject: [PATCH 19/24] Add tests for coverage of no-op focus and enabled. --- core/tests/widgets/test_activityindicator.py | 7 +++++++ core/tests/widgets/test_box.py | 8 ++++++++ core/tests/widgets/test_divider.py | 9 +++++++++ core/tests/widgets/test_label.py | 8 ++++++++ core/tests/widgets/test_progressbar.py | 12 ++++++++++++ testbed/tests/widgets/test_progressbar.py | 2 +- 6 files changed, 45 insertions(+), 1 deletion(-) diff --git a/core/tests/widgets/test_activityindicator.py b/core/tests/widgets/test_activityindicator.py index 69ccd12036..b304f04c47 100644 --- a/core/tests/widgets/test_activityindicator.py +++ b/core/tests/widgets/test_activityindicator.py @@ -111,3 +111,10 @@ def test_initially_running(): # Assert that start was invoked on the impl as part of creation. assert_action_performed(activity_indicator, "start ActivityIndicator") + + +def test_focus_noop(activity_indicator): + "Focus is a no-op." + + activity_indicator.focus() + assert_action_not_performed(activity_indicator, "focus") diff --git a/core/tests/widgets/test_box.py b/core/tests/widgets/test_box.py index c45d68d542..2a9f5d1ad1 100644 --- a/core/tests/widgets/test_box.py +++ b/core/tests/widgets/test_box.py @@ -45,3 +45,11 @@ def test_disable_no_op(): # Still enabled. assert box.enabled + + +def test_focus_noop(): + "Focus is a no-op." + box = toga.Box() + + box.focus() + assert_action_not_performed(box, "focus") diff --git a/core/tests/widgets/test_divider.py b/core/tests/widgets/test_divider.py index f8e2d6bd0b..12e02acb9c 100644 --- a/core/tests/widgets/test_divider.py +++ b/core/tests/widgets/test_divider.py @@ -3,6 +3,7 @@ import toga from toga_dummy.utils import ( EventLog, + assert_action_not_performed, assert_action_performed, ) @@ -62,3 +63,11 @@ def test_update_direction(): # The direction has been changed, and a refresh requested assert divider.direction == toga.Divider.VERTICAL assert_action_performed(divider, "refresh") + + +def test_focus_noop(): + "Focus is a no-op." + divider = toga.Divider(direction=toga.Divider.HORIZONTAL) + + divider.focus() + assert_action_not_performed(divider, "focus") diff --git a/core/tests/widgets/test_label.py b/core/tests/widgets/test_label.py index 81e3eedf02..cb77bba260 100644 --- a/core/tests/widgets/test_label.py +++ b/core/tests/widgets/test_label.py @@ -3,6 +3,7 @@ import toga from toga_dummy.utils import ( EventLog, + assert_action_not_performed, assert_action_performed, attribute_value, ) @@ -44,3 +45,10 @@ def test_update_label_text(label, value, expected): # A rehint was performed assert_action_performed(label, "refresh") + + +def test_focus_noop(label): + "Focus is a no-op." + + label.focus() + assert_action_not_performed(label, "focus") diff --git a/core/tests/widgets/test_progressbar.py b/core/tests/widgets/test_progressbar.py index 8c25b7f191..ee9f9dceab 100644 --- a/core/tests/widgets/test_progressbar.py +++ b/core/tests/widgets/test_progressbar.py @@ -239,3 +239,15 @@ def test_determinate_switch(progressbar): # State of progress bar is determinate assert progressbar.is_determinate assert progressbar.value == pytest.approx(5.0) + + +def test_disable_no_op(progressbar): + """ProgressBar doesn't have a disabled state""" + # Enabled by default + assert progressbar.enabled + + # Try to disable the widget + progressbar.enabled = False + + # Still enabled. + assert progressbar.enabled diff --git a/testbed/tests/widgets/test_progressbar.py b/testbed/tests/widgets/test_progressbar.py index 8138e28231..543888c8b5 100644 --- a/testbed/tests/widgets/test_progressbar.py +++ b/testbed/tests/widgets/test_progressbar.py @@ -9,7 +9,7 @@ test_flex_horizontal_widget_size, ) -# Buttons can't be given focus on mobile +# Progressbar can't be given focus on mobile if toga.platform.current_platform in {"android", "iOS"}: from .properties import test_focus_noop # noqa: F401 else: From 7f47831d2e49a4644aecafc5fb95d7bbbe44331b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 10 Apr 2023 10:05:14 +0800 Subject: [PATCH 20/24] Add nocover for the deletion methods on constraints. --- cocoa/src/toga_cocoa/constraints.py | 4 +++- iOS/src/toga_iOS/constraints.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/constraints.py b/cocoa/src/toga_cocoa/constraints.py index 3e51cf762b..083189dbfd 100644 --- a/cocoa/src/toga_cocoa/constraints.py +++ b/cocoa/src/toga_cocoa/constraints.py @@ -25,7 +25,9 @@ def __init__(self, widget): self.left_constraint = None self.top_constraint = None - def __del__(self): + # Deletion isn't an event we can programatically invoke; deletion + # of constraints can take several iterations before it occurs. + def __del__(self): # pragma: nocover if self.width_constraint: self.width_constraint.release() if self.height_constraint: diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index 95a7e6c62b..3a596264ff 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -25,7 +25,9 @@ def __init__(self, widget): self.left_constraint = None self.top_constraint = None - def __del__(self): + # Deletion isn't an event we can programatically invoke; deletion + # of constraints can take several iterations before it occurs. + def __del__(self): # pragma: nocover if self.width_constraint: self.width_constraint.release() if self.height_constraint: From e6673c08476c65d39798da9b1ba928693786ccae Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 11 Apr 2023 10:49:58 +0800 Subject: [PATCH 21/24] Improved docstring about focus applicability. --- core/src/toga/widgets/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index beff5850f9..c2d29ab717 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -259,9 +259,9 @@ def focus(self): """Give this widget the input focus. This method is a no-op if the widget can't accept focus. The ability of - a widget to accept focus is platform-dependent; in general, widgets on - mobile platforms only accept focus if they require keyboard input. On - desktop platforms, focus isn't directly tied to keyboard input, but - there are still some widgets that can't accept focus. + a widget to accept focus is platform-dependent. In general, on desktop + platforms you can focus any widget that can accept user input, while + on mobile platforms focus is limited to widgets that accept text input + (i.e., widgets that cause the virtual keyboard to appear). """ self._impl.focus() From 9cf7bd134659107d1abcf644cded1a67e308a4a7 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 11 Apr 2023 11:40:24 +0800 Subject: [PATCH 22/24] Avoid a memory leak when a widget is reparented. --- cocoa/src/toga_cocoa/constraints.py | 29 +++++++++++++++------------ iOS/src/toga_iOS/constraints.py | 31 ++++++++++++++++------------- iOS/src/toga_iOS/widgets/label.py | 3 +-- iOS/src/toga_iOS/widgets/slider.py | 2 +- testbed/tests/widgets/test_base.py | 7 +++++++ 5 files changed, 42 insertions(+), 30 deletions(-) diff --git a/cocoa/src/toga_cocoa/constraints.py b/cocoa/src/toga_cocoa/constraints.py index 083189dbfd..402ac4070f 100644 --- a/cocoa/src/toga_cocoa/constraints.py +++ b/cocoa/src/toga_cocoa/constraints.py @@ -28,13 +28,19 @@ def __init__(self, widget): # Deletion isn't an event we can programatically invoke; deletion # of constraints can take several iterations before it occurs. def __del__(self): # pragma: nocover - if self.width_constraint: + self._remove_constraints() + + def _remove_constraints(self): + if self.container: + # print(f"Remove constraints for {self.widget} in {self.container}") + self.container.native.removeConstraint(self.width_constraint) + self.container.native.removeConstraint(self.height_constraint) + self.container.native.removeConstraint(self.left_constraint) + self.container.native.removeConstraint(self.top_constraint) + self.width_constraint.release() - if self.height_constraint: self.height_constraint.release() - if self.left_constraint: self.left_constraint.release() - if self.top_constraint: self.top_constraint.release() @property @@ -43,15 +49,12 @@ def container(self): @container.setter def container(self, value): - if value is None and self.container: - # print(f"Remove constraints for {self.widget} in {self.container}") - self.container.native.removeConstraint(self.width_constraint) - self.container.native.removeConstraint(self.height_constraint) - self.container.native.removeConstraint(self.left_constraint) - self.container.native.removeConstraint(self.top_constraint) - self._container = value - else: - self._container = value + # This will *always* remove and then add constraints. It relies on the base widget to + # *not* invoke this setter unless the container is actually changing. + + self._remove_constraints() + self._container = value + if value is not None: # print(f"Add constraints for {self.widget} in {self.container} {self.widget.interface.layout}) self.left_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index 3a596264ff..144067cbed 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -28,13 +28,19 @@ def __init__(self, widget): # Deletion isn't an event we can programatically invoke; deletion # of constraints can take several iterations before it occurs. def __del__(self): # pragma: nocover - if self.width_constraint: + self._remove_constraints() + + def _remove_constraints(self): + if self.container: + # print(f"Remove constraints for {self.widget} in {self.container}") + self.container.native.removeConstraint(self.width_constraint) + self.container.native.removeConstraint(self.height_constraint) + self.container.native.removeConstraint(self.left_constraint) + self.container.native.removeConstraint(self.top_constraint) + self.width_constraint.release() - if self.height_constraint: self.height_constraint.release() - if self.left_constraint: self.left_constraint.release() - if self.top_constraint: self.top_constraint.release() @property @@ -43,16 +49,13 @@ def container(self): @container.setter def container(self, value): - if value is None and self.container: - # print(f"Remove constraints for {self.widget} in {self.container}") - self.container.native.removeConstraint(self.width_constraint) - self.container.native.removeConstraint(self.height_constraint) - self.container.native.removeConstraint(self.left_constraint) - self.container.native.removeConstraint(self.top_constraint) - self._container = value - else: - self._container = value - # print(f"Add constraints for {self.widget} in {self.container} {self.widget.interface.layout}) + # This will *always* remove and then add constraints. It relies on the base widget to + # *not* invoke this setter unless the container is actually changing. + + self._remove_constraints() + self._container = value + if value is not None: + # print(f"Add constraints for {self.widget} in {self.container} {self.widget.interface.layout}") self.left_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeLeft, diff --git a/iOS/src/toga_iOS/widgets/label.py b/iOS/src/toga_iOS/widgets/label.py index 96413bd031..97d74eac9e 100644 --- a/iOS/src/toga_iOS/widgets/label.py +++ b/iOS/src/toga_iOS/widgets/label.py @@ -53,9 +53,8 @@ def rehint(self): # fit. To avoid this, temporarily relax the width and height constraint # on the widget to "effectively infinite" values; they will be # re-applied as part of the application of the newly hinted layout. - if self.constraints.width_constraint: + if self.constraints.container: self.constraints.width_constraint.constant = 100000 - if self.constraints.height_constraint: self.constraints.height_constraint.constant = 100000 fitting_size = self.native.systemLayoutSizeFittingSize(CGSize(0, 0)) # print(f"REHINT label {self} {self.get_text()!r} {fitting_size.width} {fitting_size.height}") diff --git a/iOS/src/toga_iOS/widgets/slider.py b/iOS/src/toga_iOS/widgets/slider.py index 8fe076360d..e124968e44 100644 --- a/iOS/src/toga_iOS/widgets/slider.py +++ b/iOS/src/toga_iOS/widgets/slider.py @@ -96,6 +96,6 @@ def set_tick_count(self, tick_count): self.tick_count = tick_count def rehint(self): - fitting_size = self.native.systemLayoutSizeFittingSize_(CGSize(0, 0)) + fitting_size = self.native.systemLayoutSizeFittingSize(CGSize(0, 0)) self.interface.intrinsic.width = at_least(fitting_size.width) self.interface.intrinsic.height = fitting_size.height diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py index 42abe0b5e1..9148e60b38 100644 --- a/testbed/tests/widgets/test_base.py +++ b/testbed/tests/widgets/test_base.py @@ -80,6 +80,13 @@ async def test_parenting(widget, probe): other_probe.assert_layout(position=(100, 0), size=(100, 200)) child_probe.assert_layout(position=(0, 0), size=(50, 75)) + # Re-add child to the *same* widget + widget.add(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_layout(position=(0, 0), size=(50, 75)) + # Reparent child to other without removing first other.add(child) await probe.redraw() From a0fcd309fe952c2b28df7f8fc631424f8efe5454 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 11 Apr 2023 12:58:31 +0800 Subject: [PATCH 23/24] Ensure hidden children aren't made visible if their parent is visible. --- core/src/toga/style/applicator.py | 11 +++- core/src/toga/style/pack.py | 7 ++- core/tests/style/test_applicator.py | 98 ++++++++++++++++++++++------- docs/reference/style/pack.rst | 17 ++--- testbed/tests/widgets/test_base.py | 50 ++++++++++++++- 5 files changed, 147 insertions(+), 36 deletions(-) diff --git a/core/src/toga/style/applicator.py b/core/src/toga/style/applicator.py index 2df5b92619..ee985c4f67 100644 --- a/core/src/toga/style/applicator.py +++ b/core/src/toga/style/applicator.py @@ -25,7 +25,16 @@ def set_text_alignment(self, alignment): def set_hidden(self, hidden): self.widget._impl.set_hidden(hidden) for child in self.widget.children: - child.applicator.set_hidden(hidden) + # If the parent is hidden, then so are all children. However, if the + # parent is visible, then the child's explicit visibility style is + # taken into account. This visibility cascades into any + # grandchildren. + # + # parent hidden child hidden style child final hidden state + # ============= ================== ======================== True + # True True True False True + # False True True False False False + child.applicator.set_hidden(hidden or child.style._hidden) def set_font(self, font): # Changing the font of a widget can make the widget change size, diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index d1990ae714..e56f791f66 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -81,9 +81,14 @@ class IntrinsicSize(BaseIntrinsicSize): _depth = -1 - def _debug(self, *args): + def _debug(self, *args): # pragma: no cover print(" " * self.__class__._depth, *args) + @property + def _hidden(self): + "Does this style declaration define a object that should be hidden" + return self.visibility == HIDDEN + def apply(self, prop, value): if self._applicator: if prop == "text_align": diff --git a/core/tests/style/test_applicator.py b/core/tests/style/test_applicator.py index 288198f045..c68385b75c 100644 --- a/core/tests/style/test_applicator.py +++ b/core/tests/style/test_applicator.py @@ -3,7 +3,7 @@ import toga from toga.colors import REBECCAPURPLE from toga.fonts import FANTASY -from toga.style.pack import RIGHT, VISIBLE +from toga.style.pack import HIDDEN, RIGHT, VISIBLE from toga_dummy.utils import assert_action_performed_with @@ -25,8 +25,16 @@ def __init__(self, *args, **kwargs): @pytest.fixture -def child(): - return TestLeafWidget(id="child_id") +def grandchild(): + return TestLeafWidget(id="grandchild_id") + + +@pytest.fixture +def child(grandchild): + child = TestWidget(id="child_id") + child.add(grandchild) + + return child @pytest.fixture @@ -44,25 +52,34 @@ def test_refresh(widget): assert_action_performed_with(widget, "refresh") -def test_set_bounds(child, widget): +def test_set_bounds(widget, child, grandchild): "Bounds changes are passed to all widgets in the tree" # Manually set location of the parent - widget.layout._origin_left = 10 - widget.layout._origin_top = 20 - widget.layout.content_width = 30 - widget.layout.content_height = 40 + widget.layout._origin_left = 100 + widget.layout._origin_top = 200 + widget.layout.content_width = 300 + widget.layout.content_height = 400 # Manually set location of the child - child.layout._origin_left = 1 - child.layout._origin_top = 2 - child.layout.content_width = 3 - child.layout.content_height = 4 + child.layout._origin_left = 10 + child.layout._origin_top = 20 + child.layout.content_width = 30 + child.layout.content_height = 40 + + # Manually set location of the grandchild + grandchild.layout._origin_left = 1 + grandchild.layout._origin_top = 2 + grandchild.layout.content_width = 3 + grandchild.layout.content_height = 4 # Propegate the boundsq widget.applicator.set_bounds() - assert_action_performed_with(widget, "set bounds", x=10, y=20, width=30, height=40) - assert_action_performed_with(child, "set bounds", x=1, y=2, width=3, height=4) + assert_action_performed_with( + widget, "set bounds", x=100, y=200, width=300, height=400 + ) + assert_action_performed_with(child, "set bounds", x=10, y=20, width=30, height=40) + assert_action_performed_with(grandchild, "set bounds", x=1, y=2, width=3, height=4) def test_text_alignment(widget): @@ -72,15 +89,50 @@ def test_text_alignment(widget): assert_action_performed_with(widget, "set alignment", alignment=RIGHT) -def test_set_hidden(widget, child): - "Visibility can be set on a widget" - widget.applicator.set_hidden(True) - - assert_action_performed_with(widget, "set hidden", hidden=True) - # The hide is applied transitively to the child - assert_action_performed_with(child, "set hidden", hidden=True) - # However, the style property of the child hasn't changed. - assert child.style.visibility == VISIBLE +@pytest.mark.parametrize( + "child_visibility, grandchild_visibility, value, " + "widget_hidden, child_hidden, grandchild_hidden", + [ + # Set widget hidden. All widgets are always hidden, + # no matter the actual style setting. + (VISIBLE, VISIBLE, True, True, True, True), + (VISIBLE, HIDDEN, True, True, True, True), + (HIDDEN, VISIBLE, True, True, True, True), + (HIDDEN, HIDDEN, True, True, True, True), + # Set widget visible. Visibility only cascades + # as far as the first HIDDEN widget + (VISIBLE, VISIBLE, False, False, False, False), + (VISIBLE, HIDDEN, False, False, False, True), + (HIDDEN, VISIBLE, False, False, True, True), + (HIDDEN, HIDDEN, False, False, True, True), + ], +) +def test_set_hidden( + widget, + child, + grandchild, + child_visibility, + grandchild_visibility, + value, + widget_hidden, + child_hidden, + grandchild_hidden, +): + """Widget visibility can be controlled, and is transitive into children""" + # Set the explicit visibility of the child and grandchild + child.style.visibility = child_visibility + grandchild.style.visibility = grandchild_visibility + + # Set widget visibility + widget.applicator.set_hidden(value) + + assert_action_performed_with(widget, "set hidden", hidden=widget_hidden) + assert_action_performed_with(child, "set hidden", hidden=child_hidden) + assert_action_performed_with(grandchild, "set hidden", hidden=grandchild_hidden) + + # The style property of the child and grandchild hasn't changed. + assert child.style.visibility == child_visibility + assert grandchild.style.visibility == grandchild_visibility def test_set_font(widget): diff --git a/docs/reference/style/pack.rst b/docs/reference/style/pack.rst index bb4df618da..06505795c3 100644 --- a/docs/reference/style/pack.rst +++ b/docs/reference/style/pack.rst @@ -42,14 +42,15 @@ visible. **Initial value:** ``visible`` -Used to define whether the element should be drawn. A value of ``visible`` -means the element will be displayed. A value of ``none`` removes the element, -but still allocates space for the element as if it were in the element tree. - -If an element with children is hidden, all it's children will be implicitly -removed from view. However, if one of those children is to a parent that is -*not* hidden, it will become visible again unless that widget has been -*explicitly* marked as `hidden`. +Used to define whether the element should be drawn. A value of ``visible`` means +the element will be displayed. A value of ``hidden`` removes the element from +view, but allocates space for the element as if it were still in the layout. + +Any children of a hidden element are implicitly removed from view. + +If a previously hidden element is made visible, any children of the element with +a visibility of ``hidden`` will remain hidden. Any descendants of the hidden +child will also remain hidden, regardless of their visibility. ``direction`` ------------- diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py index 9148e60b38..9a049439ff 100644 --- a/testbed/tests/widgets/test_base.py +++ b/testbed/tests/widgets/test_base.py @@ -15,9 +15,13 @@ async def widget(): async def test_visibility(widget, probe): "A widget (and it's children) can be made invisible" - child = toga.Button("Hello") + child = toga.Box(style=Pack(width=75, height=100, background_color=GREEN)) child_probe = get_probe(child) + grandchild = toga.Button("Hello") + grandchild_probe = get_probe(grandchild) + child.add(grandchild) + other = toga.Box(style=Pack(width=100, height=200, background_color=BLUE)) other_probe = get_probe(other) @@ -29,6 +33,7 @@ async def test_visibility(widget, probe): # Widgets are all visible an in place assert not probe.is_hidden assert not child_probe.is_hidden + assert not grandchild_probe.is_hidden probe.assert_layout(position=(0, 0), size=(100, 200)) other_probe.assert_layout(position=(100, 0), size=(100, 200)) @@ -36,9 +41,10 @@ async def test_visibility(widget, probe): widget.style.visibility = HIDDEN await probe.redraw() - # Widget and child are no longer visible. + # Widgets are no longer visible. assert probe.is_hidden assert child_probe.is_hidden + assert grandchild_probe.is_hidden # Making the widget invisible doesn't affect layout probe.assert_layout(position=(0, 0), size=(100, 200)) other_probe.assert_layout(position=(100, 0), size=(100, 200)) @@ -47,12 +53,50 @@ async def test_visibility(widget, probe): widget.style.visibility = VISIBLE await probe.redraw() - # Widgets and child are visible and in place again. + # Widgets are all visible and in place again. assert not probe.is_hidden assert not child_probe.is_hidden + assert not grandchild_probe.is_hidden probe.assert_layout(position=(0, 0), size=(100, 200)) other_probe.assert_layout(position=(100, 0), size=(100, 200)) + # Hide the widget again + widget.style.visibility = HIDDEN + await probe.redraw() + + # Widgets are no longer visible. + assert probe.is_hidden + assert child_probe.is_hidden + assert grandchild_probe.is_hidden + + # Mark the child style as visible. + child.style.visibility = VISIBLE + await probe.redraw() + + # Root widget isn't visible, so neither descendent is visible. + assert probe.is_hidden + assert child_probe.is_hidden + assert grandchild_probe.is_hidden + + # Explicitly mark the child style as hidden. + child.style.visibility = HIDDEN + await probe.redraw() + + # Root widget isn't visible, so neither descendent is visible. + assert probe.is_hidden + assert child_probe.is_hidden + assert grandchild_probe.is_hidden + + # Mark the root widget as visible again. + widget.style.visibility = VISIBLE + await probe.redraw() + + # Root widget is visible again but the child is explicitly hidden, + # so it and the grandchild are still hidden + assert not probe.is_hidden + assert child_probe.is_hidden + assert grandchild_probe.is_hidden + async def test_parenting(widget, probe): "A widget can be reparented between containers" From 1c8f0bb028b188e377e8391766ccd78ef742428b Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 11 Apr 2023 12:35:38 +0100 Subject: [PATCH 24/24] FIx comment layout --- core/src/toga/style/applicator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/src/toga/style/applicator.py b/core/src/toga/style/applicator.py index ee985c4f67..33e733c15b 100644 --- a/core/src/toga/style/applicator.py +++ b/core/src/toga/style/applicator.py @@ -31,9 +31,11 @@ def set_hidden(self, hidden): # grandchildren. # # parent hidden child hidden style child final hidden state - # ============= ================== ======================== True - # True True True False True - # False True True False False False + # ============= ================== ======================== + # True True True + # True False True + # False True True + # False False False child.applicator.set_hidden(hidden or child.style._hidden) def set_font(self, font):