From 40a7dd03cd1f6775352bdca10dc6f90e7a8fb742 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 8 Jun 2023 15:25:47 +0800 Subject: [PATCH 01/40] Update docs for ScrollContainer --- .../api/containers/scrollcontainer.rst | 29 +++++------------- docs/reference/api/index.rst | 3 +- docs/reference/data/widgets_by_platform.csv | 2 +- docs/reference/images/ScrollContainer.jpeg | Bin 15254 -> 0 bytes docs/reference/images/ScrollContainer.png | Bin 0 -> 12376 bytes 5 files changed, 10 insertions(+), 24 deletions(-) delete mode 100644 docs/reference/images/ScrollContainer.jpeg create mode 100644 docs/reference/images/ScrollContainer.png diff --git a/docs/reference/api/containers/scrollcontainer.rst b/docs/reference/api/containers/scrollcontainer.rst index daa93dfd34..dfd86ddaec 100644 --- a/docs/reference/api/containers/scrollcontainer.rst +++ b/docs/reference/api/containers/scrollcontainer.rst @@ -1,6 +1,12 @@ Scroll Container ================ +An container widget that can display a layout larger that the area of the +container, with overflow controlled by scrollbars. + +.. figure:: /reference/images/ScrollContainer.png + :align: center + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,12 +14,6 @@ Scroll Container :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!(ScrollContainer|Component))'} -The Scroll Container is similar to the ``iframe`` or scrollable ``div`` element in HTML, it contains an object with -its own scrollable selection. - -.. figure:: /reference/images/ScrollContainer.jpeg - :align: center - Usage ----- @@ -21,25 +21,10 @@ Usage import toga - content = toga.WebView() + content = toga.Box(children=[...]) container = toga.ScrollContainer(content=content) -Scroll settings ---------------- - -Horizontal or vertical scroll can be set via the initializer or using the property. - -.. code-block:: python - - import toga - - content = toga.WebView() - - container = toga.ScrollContainer(content=content, horizontal=False) - - container.vertical = False - Reference --------- diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index a64b47eabc..61e40e81d6 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -58,7 +58,8 @@ Layout widgets Usage Description ==================================================================== ======================================================================== :doc:`Box ` A generic container for other widgets. Used to construct layouts. - :doc:`ScrollContainer ` Scrollable Container + :doc:`ScrollContainer ` An container widget that can display a layout larger that the area of + the container, with overflow controlled by scrollbars. :doc:`SplitContainer ` Split Container :doc:`OptionContainer ` Option Container ==================================================================== ======================================================================== diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 96c93094c6..8b1f704994 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -24,7 +24,7 @@ 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,|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|, +ScrollContainer,Layout Widget,:class:`~toga.widgets.scrollcontainer.ScrollContainer`,An container widget that can display a layout larger that the area of the container, with overflow controlled by scrollbars.,|b|,|b|,|b|,|b|,|b|, SplitContainer,Layout Widget,:class:`~toga.widgets.splitcontainer.SplitContainer`,Split Container,|b|,|b|,|b|,,, OptionContainer,Layout Widget,:class:`~toga.widgets.optioncontainer.OptionContainer`,Option Container,|b|,|b|,|b|,,, App Paths,Resource,:class:~`toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|, diff --git a/docs/reference/images/ScrollContainer.jpeg b/docs/reference/images/ScrollContainer.jpeg deleted file mode 100644 index 77328330ed8e50cc0ee15cca426e6990ff5120cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15254 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 diff --git a/docs/reference/images/ScrollContainer.png b/docs/reference/images/ScrollContainer.png new file mode 100644 index 0000000000000000000000000000000000000000..0e7c063e92c496b5554ca1e8e0705f69544d7741 GIT binary patch literal 12376 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 literal 0 HcmV?d00001 From e256720ecf132dc906cb94c574416acc8c5e5d23 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 8 Jun 2023 15:26:58 +0800 Subject: [PATCH 02/40] Update core API for ScrollContainer. --- core/src/toga/widgets/scrollcontainer.py | 128 +++--- core/tests/test_deprecated_factory.py | 6 - core/tests/widgets/test_scrollcontainer.py | 435 ++++++++++++++---- .../src/toga_dummy/widgets/scrollcontainer.py | 14 +- 4 files changed, 415 insertions(+), 168 deletions(-) diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index d4dc1f5336..6974ad57c1 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -1,20 +1,11 @@ -import warnings +from __future__ import annotations + +from toga.handlers import wrapped_handler from .base import Widget class ScrollContainer(Widget): - """Instantiate a new instance of the scrollable container widget. - - Args: - id (str): An identifier for this widget. - style (:obj:`Style`): An optional style object. - If no style is provided then a new one will be created for the widget. - horizontal (bool): If True enable horizontal scroll bar. - vertical (bool): If True enable vertical scroll bar. - content (:class:`~toga.widgets.base.Widget`): The content of the scroll window. - """ - MIN_WIDTH = 100 MIN_HEIGHT = 100 @@ -22,28 +13,26 @@ def __init__( self, id=None, style=None, - horizontal=True, - vertical=True, - on_scroll=None, - content=None, - factory=None, # DEPRECATED! + horizontal: bool = True, + vertical: bool = True, + on_scroll: callable | None = None, + content: Widget | None = None, ): + """Create a new Scroll Container. + + Inherits from :class:`~toga.widgets.base.Widget`. + + :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. + :param horizontal: Should horizontal scrolling be permitted? + :param vertical: Should horizontal scrolling be permitted? + :param on_scroll: Initial :any:`on_scroll` handler. + :param content: The content to display in the scroll window. + """ super().__init__(id=id, style=style) - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### - - self._vertical = vertical - self._horizontal = horizontal self._content = None - # Create a platform specific implementation of a Scroll Container self._impl = self.factory.ScrollContainer(interface=self) @@ -59,8 +48,8 @@ def app(self, app): Widget.app.fset(self, app) # Also assign the app to the content in the container - if self.content: - self.content.app = app + if self._content: + self._content.app = app @Widget.window.setter def window(self, window): @@ -72,26 +61,27 @@ def window(self, window): self._content.window = window @property - def content(self): - """Content of the scroll container. - - Returns: - The content of the widget (:class:`~toga.widgets.base.Widget`). - """ + def content(self) -> Widget: + """The root content widget displayed inside the scroll container.""" return self._content @content.setter def content(self, widget): + if self._content: + self._content.app = None + self._content.window = None + if widget: widget.app = self.app widget.window = self.window self._content = widget - self._impl.set_content(widget._impl) - self.refresh() + else: + self._content = None + self._impl.set_content(None) - widget.refresh() + self.refresh() def refresh_sublayouts(self): """Refresh the layout and appearance of this widget.""" @@ -99,44 +89,44 @@ def refresh_sublayouts(self): self._content.refresh() @property - def vertical(self): - """Shows whether vertical scrolling is enabled. - - Returns: - (bool) True if enabled, False if disabled. - """ - return self._vertical + def vertical(self) -> bool: + """Is vertical scrolling enabled?""" + return self._impl.get_vertical() @vertical.setter def vertical(self, value): - self._vertical = value - self._impl.set_vertical(value) + self._impl.set_vertical(bool(value)) + self.refresh_sublayouts() @property - def horizontal(self): - """Shows whether horizontal scrolling is enabled. - - Returns: - (bool) True if enabled, False if disabled. - """ - return self._horizontal + def horizontal(self) -> bool: + """Is horizontal scrolling enabled?""" + return self._impl.get_horizontal() @horizontal.setter def horizontal(self, value): - self._horizontal = value - self._impl.set_horizontal(value) + self._impl.set_horizontal(bool(value)) + self.refresh_sublayouts() @property - def on_scroll(self): + def on_scroll(self) -> callable: + """Handler to invoke when the user moves a scroll bar.""" return self._on_scroll @on_scroll.setter def on_scroll(self, on_scroll): - self._on_scroll = on_scroll - self._impl.set_on_scroll(on_scroll) + self._on_scroll = wrapped_handler(self, on_scroll) @property - def horizontal_position(self): + def horizontal_position(self) -> float: + """The current horizontal scroller position. + + Raises :any:`ValueError` if horizontal scrolling is not enabled. + """ + if not self.horizontal: + raise ValueError( + "Cannot get horizontal position when horizontal is not set." + ) return self._impl.get_horizontal_position() @horizontal_position.setter @@ -145,14 +135,20 @@ def horizontal_position(self, horizontal_position): raise ValueError( "Cannot set horizontal position when horizontal is not set." ) - self._impl.set_horizontal_position(horizontal_position) + self._impl.set_horizontal_position(float(horizontal_position)) @property - def vertical_position(self): + def vertical_position(self) -> float: + """The current vertical scroller position. + + Raises :any:`ValueError` if vertical scrolling is not enabled. + """ + if not self.vertical: + raise ValueError("Cannot get vertical position when vertical is not set.") return self._impl.get_vertical_position() @vertical_position.setter def vertical_position(self, vertical_position): if not self.vertical: raise ValueError("Cannot set vertical position when vertical is not set.") - self._impl.set_vertical_position(vertical_position) + self._impl.set_vertical_position(float(vertical_position)) diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index c35818bd24..bb01429c94 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -105,12 +105,6 @@ def test_option_container_created(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_scroll_container_created(self): - with self.assertWarns(DeprecationWarning): - widget = toga.ScrollContainer(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_selection_created(self): with self.assertWarns(DeprecationWarning): widget = toga.Selection(factory=self.factory) diff --git a/core/tests/widgets/test_scrollcontainer.py b/core/tests/widgets/test_scrollcontainer.py index 0fcf7aa0b4..2c949aa4b4 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -1,94 +1,345 @@ -from unittest import mock +from unittest.mock import Mock + +import pytest import toga -from toga_dummy.utils import TestCase, TestStyle - - -class ScrollContainerTests(TestCase): - def setUp(self): - super().setUp() - - self.on_scroll = mock.Mock() - self.sc = toga.ScrollContainer(style=TestStyle(), on_scroll=self.on_scroll) - - def test_widget_created(self): - self.assertEqual(self.sc._impl.interface, self.sc) - self.assertActionPerformed(self.sc, "create ScrollContainer") - - def test_on_scroll_is_set(self): - self.assertValueSet(self.sc, "on_scroll", self.on_scroll) - self.assertEqual(self.sc.on_scroll, self.on_scroll) - - def test_initial_scroll_position_is_zero(self): - self.assertEqual(self.sc.horizontal_position, 0) - self.assertEqual(self.sc.vertical_position, 0) - - def test_set_horizontal_scroll_position(self): - horizontal_position = 0.5 - self.sc.horizontal_position = horizontal_position - self.assertValueSet(self.sc, "horizontal_position", horizontal_position) - self.assertEqual(self.sc.horizontal_position, horizontal_position) - self.assertEqual(self.sc.vertical_position, 0) - - def test_set_vertical_scroll_position(self): - vertical_position = 0.5 - self.sc.vertical_position = vertical_position - self.assertValueSet(self.sc, "vertical_position", vertical_position) - self.assertEqual(self.sc.horizontal_position, 0) - self.assertEqual(self.sc.vertical_position, vertical_position) - - def test_set_content_with_widget(self): - self.assertEqual( - self.sc.content, None, "The default value of content should be None" - ) - - new_content = toga.Box(style=TestStyle()) - self.sc.content = new_content - self.assertEqual(self.sc.content, new_content) - self.assertEqual(self.sc._content, new_content) - self.assertActionPerformedWith(self.sc, "set content", widget=new_content._impl) - - def test_set_content_with_None(self): - new_content = None - self.assertEqual(self.sc.content, new_content) - self.assertEqual(self.sc._content, new_content) - self.assertActionNotPerformed(self.sc, "set content") - - def test_vertical_property(self): - self.assertEqual(self.sc.vertical, True, "The default should be True") - - new_value = False - self.sc.vertical = new_value - self.assertEqual(self.sc.vertical, new_value) - self.assertValueSet(self.sc, "vertical", new_value) - - def test_horizontal_property(self): - self.assertEqual(self.sc.horizontal, True, "The default should be True") - - new_value = False - self.sc.horizontal = new_value - self.assertEqual(self.sc.horizontal, new_value) - self.assertValueSet(self.sc, "horizontal", new_value) - - def test_set_horizontal_position_when_unset_raises_an_error(self): - self.sc.horizontal = False - with self.assertRaisesRegex( - ValueError, "^Cannot set horizontal position when horizontal is not set.$" - ): - self.sc.horizontal_position = 0.5 - - def test_set_vertical_position_when_unset_raises_an_error(self): - self.sc.vertical = False - with self.assertRaisesRegex( - ValueError, "^Cannot set vertical position when vertical is not set.$" - ): - self.sc.vertical_position = 0.5 - - def test_set_app(self): - new_content = toga.Box(style=TestStyle()) - self.sc.content = new_content - self.assertIsNone(new_content.app) - - app = mock.Mock() - self.sc.app = app - self.assertEqual(new_content.app, app) +from toga_dummy.utils import ( + EventLog, + assert_action_performed, + assert_action_performed_with, +) + + +@pytest.fixture +def app(): + return toga.App("Scroll Container Test", "org.beeware.toga.scroll_container") + + +@pytest.fixture +def window(): + return toga.Window() + + +@pytest.fixture +def content(): + return toga.Box() + + +@pytest.fixture +def on_scroll_handler(): + return Mock() + + +@pytest.fixture +def scroll_container(content, on_scroll_handler): + return toga.ScrollContainer(content=content, on_scroll=on_scroll_handler) + + +def test_widget_created(): + "A scroll container can be created with no arguments" + scroll_container = toga.ScrollContainer() + assert scroll_container._impl.interface == scroll_container + assert_action_performed(scroll_container, "create ScrollContainer") + + assert scroll_container.content is None + assert scroll_container.vertical + assert scroll_container.horizontal + assert scroll_container.on_scroll._raw is None + + +def test_widget_created_with_values(content, on_scroll_handler): + "A scroll container can be created with no arguments" + scroll_container = toga.ScrollContainer( + content=content, + on_scroll=on_scroll_handler, + vertical=False, + horizontal=False, + ) + assert scroll_container._impl.interface == scroll_container + assert_action_performed(scroll_container, "create ScrollContainer") + + assert scroll_container.content == content + assert not scroll_container.vertical + assert not scroll_container.horizontal + assert scroll_container.on_scroll._raw == on_scroll_handler + + # The content has been assigned to the widget + assert_action_performed_with(scroll_container, "set content", widget=content._impl) + + # The scroll container has been refreshed + assert_action_performed(scroll_container, "refresh") + + # The content has been refreshed + assert_action_performed(content, "refresh") + + # The scroll handler hasn't been invoked + on_scroll_handler.assert_not_called() + + +def test_assign_to_app(app, scroll_container, content): + """If the widget is assigned to an app, the content is also assigned""" + # Scroll container is initially unassigned + assert scroll_container.app is None + + # Assign the scroll container to the app + scroll_container.app = app + + # Scroll container is on the app + assert scroll_container.app == app + # Content is also on the app + assert content.app == app + + +def test_assign_to_app_no_content(app): + """If the widget is assigned to an app, and there is no content, nothing happens""" + scroll_container = toga.ScrollContainer() + + # Scroll container is initially unassigned + assert scroll_container.app is None + + # Assign the scroll container to the app + scroll_container.app = app + + # Scroll container is on the app + assert scroll_container.app == app + + +def test_assign_to_window(window, scroll_container, content): + """If the widget is assigned to a window, the content is also assigned""" + # Scroll container is initially unassigned + assert scroll_container.window is None + + # Assign the scroll container to the window + scroll_container.window = window + + # Scroll container is on the window + assert scroll_container.window == window + # Content is also on the window + assert content.window == window + + +def test_assign_to_window_no_content(window): + """If the widget is assigned to an app, and there is no content, nothing happens""" + scroll_container = toga.ScrollContainer() + + # Scroll container is initially unassigned + assert scroll_container.window is None + + # Assign the scroll container to the window + scroll_container.window = window + + # Scroll container is on the window + assert scroll_container.window == window + + +def test_set_content(app, window, scroll_container, content): + """The content of the scroll container can be changed""" + # Assign the scroll container to an app and window + scroll_container.app = app + scroll_container.window = window + + # The content is also assigned + assert content.app == app + assert content.window == window + + # Reset the event log + EventLog.reset() + + # Create new content, and assign it to the scroll container + new_content = toga.Box() + scroll_container.content = new_content + + # The content has been assigned to the widget + assert_action_performed_with( + scroll_container, + "set content", + widget=new_content._impl, + ) + + # The scroll container has been refreshed + assert_action_performed(scroll_container, "refresh") + + # The new content has been refreshed + assert_action_performed(new_content, "refresh") + + # The content has been assigned + assert scroll_container.content == new_content + + # The new content is assigned to + assert new_content.app == app + assert new_content.window == window + + # The old content isn't + assert content.app is None + assert content.window is None + + +def test_clear_content(app, window, scroll_container, content): + """The content of the scroll container can be cleared""" + # Assign the scroll container to an app and window + scroll_container.app = app + scroll_container.window = window + + # The content is also assigned + assert content.app == app + assert content.window == window + + # Reset the event log + EventLog.reset() + + # Clear the content + scroll_container.content = None + + # The content has been assigned to the widget + assert_action_performed_with( + scroll_container, + "set content", + widget=None, + ) + + # The scroll container has been refreshed + assert_action_performed(scroll_container, "refresh") + + # The content has been cleared + assert scroll_container.content is None + + # The old content isn't assigned any more + assert content.app is None + assert content.window is None + + +def test_content(app, window, scroll_container, content): + """The content of the scroll container can be changed""" + # Assign the scroll container to an app and window + scroll_container.app = app + scroll_container.window = window + + # The content is also assigned + assert content.app == app + assert content.window == window + + # Reset the event log + EventLog.reset() + + # Create new content, and assign it to the scroll container + new_content = toga.Box() + scroll_container.content = new_content + + # The content has been assigned to the widget + assert_action_performed_with( + scroll_container, + "set content", + widget=new_content._impl, + ) + + # The new content has been refreshed + assert_action_performed(new_content, "refresh") + + # The content has been assigned + assert scroll_container.content == new_content + + # The new content is assigned to + assert new_content.app == app + assert new_content.window == window + + # The old content isn't + assert content.app is None + assert content.window is None + + +@pytest.mark.parametrize( + "value, expected", + [ + (True, True), + (False, False), + (42, True), + (0, False), + ("True", True), + ("False", True), # non-empty string is truthy + ("", False), + (object(), True), + ], +) +def test_horizontal(scroll_container, content, value, expected): + "Horizontal scrolling can be enabled/disabled." + scroll_container.horizontal = value + scroll_container.horizontal == expected + + # Content is refreshed as a result of the change + assert_action_performed(content, "refresh") + + +@pytest.mark.parametrize( + "value, expected", + [ + (True, True), + (False, False), + (42, True), + (0, False), + ("True", True), + ("False", True), # non-empty string is truthy + ("", False), + (object(), True), + ], +) +def test_vertical(scroll_container, content, value, expected): + "Vertical scrolling can be enabled/disabled." + scroll_container.vertical = value + scroll_container.vertical == expected + + # Content is refreshed as a result of the change + assert_action_performed(content, "refresh") + + +def test_horizontal_position(scroll_container): + "The horizontal position can be set and retrieved" + scroll_container.horizontal_position = 10 + + assert scroll_container.horizontal_position == 10 + + +def test_get_horizontal_position_when_not_horizontal(scroll_container): + "If horizontal scrolling isn't enabled, getting the horizontal position raises an error" + scroll_container.horizontal = False + with pytest.raises( + ValueError, + match=r"Cannot get horizontal position when horizontal is not set.", + ): + scroll_container.horizontal_position + + +def test_set_horizontal_position_when_not_horizontal(scroll_container): + "If horizontal scrolling isn't enabled, setting the horizontal position raises an error" + scroll_container.horizontal = False + with pytest.raises( + ValueError, + match=r"Cannot set horizontal position when horizontal is not set.", + ): + scroll_container.horizontal_position = 0.5 + + +def test_vertical_position(scroll_container): + "The vertical position can be set and retrieved" + scroll_container.vertical_position = 10 + + assert scroll_container.vertical_position == 10 + + +def test_get_vertical_position_when_not_vertical(scroll_container): + "If vertical scrolling isn't enabled, getting the vertical position raises an error" + scroll_container.vertical = False + with pytest.raises( + ValueError, + match=r"Cannot get vertical position when vertical is not set.", + ): + scroll_container.vertical_position + + +def test_set_vertical_position_when_not_vertical(scroll_container): + "If vertical scrolling isn't enabled, setting the vertical position raises an error" + scroll_container.vertical = False + with pytest.raises( + ValueError, + match=r"Cannot set vertical position when vertical is not set.", + ): + scroll_container.vertical_position = 0.5 diff --git a/dummy/src/toga_dummy/widgets/scrollcontainer.py b/dummy/src/toga_dummy/widgets/scrollcontainer.py index 5a5afc83b6..969f4dd5ae 100644 --- a/dummy/src/toga_dummy/widgets/scrollcontainer.py +++ b/dummy/src/toga_dummy/widgets/scrollcontainer.py @@ -1,6 +1,8 @@ +from ..utils import not_required from .base import Widget +@not_required # Testbed coverage is complete for this widget. class ScrollContainer(Widget): def create(self): self._action("create ScrollContainer") @@ -10,9 +12,15 @@ def create(self): def set_content(self, widget): self._action("set content", widget=widget) + def get_vertical(self): + return self._get_value("vertical", True) + def set_vertical(self, value): self._set_value("vertical", value) + def get_horizontal(self): + return self._get_value("horizontal", True) + def set_horizontal(self, value): self._set_value("horizontal", value) @@ -21,14 +29,12 @@ def set_on_scroll(self, on_scroll): def set_horizontal_position(self, horizontal_position): self._set_value("horizontal_position", horizontal_position) - self._horizontal_position = horizontal_position def get_horizontal_position(self): - return self._horizontal_position + return self._get_value("horizontal_position", 0) def set_vertical_position(self, vertical_position): self._set_value("vertical_position", vertical_position) - self._vertical_position = vertical_position def get_vertical_position(self): - return self._vertical_position + return self._get_value("vertical_position", 0) From 6705fe978cf9a90dd2afc74c0e7a5ede58051835 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 9 Jun 2023 09:30:22 +0800 Subject: [PATCH 03/40] Add changenote. --- changes/1969.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/1969.feature.rst diff --git a/changes/1969.feature.rst b/changes/1969.feature.rst new file mode 100644 index 0000000000..1c57c4e265 --- /dev/null +++ b/changes/1969.feature.rst @@ -0,0 +1 @@ +The ScrollConatiner widget now has 100% test coverage, and complete API documentation. From e06281d4c21949806aa5d1320cb0e7fed9af56a3 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 10 Jun 2023 13:50:12 +0800 Subject: [PATCH 04/40] Initial fixes for Cocoa, plus cocoa test probe for scrollcontainer. --- cocoa/src/toga_cocoa/libs/appkit.py | 8 + .../src/toga_cocoa/widgets/scrollcontainer.py | 124 ++++++++-- .../tests_backend/widgets/scrollcontainer.py | 23 ++ core/src/toga/widgets/scrollcontainer.py | 70 ++++-- core/tests/widgets/test_scrollcontainer.py | 101 +++----- .../src/toga_dummy/widgets/scrollcontainer.py | 20 ++ testbed/tests/widgets/test_scrollcontainer.py | 218 ++++++++++++++++++ 7 files changed, 463 insertions(+), 101 deletions(-) create mode 100644 cocoa/tests_backend/widgets/scrollcontainer.py create mode 100644 testbed/tests/widgets/test_scrollcontainer.py diff --git a/cocoa/src/toga_cocoa/libs/appkit.py b/cocoa/src/toga_cocoa/libs/appkit.py index 53910850ef..aed5781da1 100644 --- a/cocoa/src/toga_cocoa/libs/appkit.py +++ b/cocoa/src/toga_cocoa/libs/appkit.py @@ -603,6 +603,14 @@ class NSLineBreakMode(Enum): NSScrollElasticityNone = 1 NSScrollElasticityAllowed = 2 +NSScrollViewDidLiveScrollNotification = objc_const( + appkit, "NSScrollViewDidLiveScrollNotification" +) +NSScrollViewDidEndLiveScrollNotification = objc_const( + appkit, "NSScrollViewDidEndLiveScrollNotification" +) + + ###################################################################### # NSSecureTextField.h NSSecureTextField = ObjCClass("NSSecureTextField") diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 2ab06edf87..f7015cdf07 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -1,14 +1,44 @@ +from rubicon.objc import SEL, objc_method, objc_property from travertino.size import at_least -from toga_cocoa.libs import NSColor, NSMakeRect, NSNoBorder, NSScrollView +from toga_cocoa.libs import ( + NSColor, + NSMakePoint, + NSMakeRect, + NSNoBorder, + NSNotificationCenter, + NSScrollView, + NSScrollViewDidEndLiveScrollNotification, + NSScrollViewDidLiveScrollNotification, +) from toga_cocoa.window import CocoaViewport from .base import Widget +class TogaScrollView(NSScrollView): + interface = objc_property(object, weak=True) + impl = objc_property(object, weak=True) + + @objc_method + def didScroll_(self, note) -> None: + # print( + # f"SCROLL frame={self.impl.native.frame.size.width}x{self.impl.native.frame.size.height}" + # f" @ {self.impl.native.frame.origin.x}x{self.impl.native.frame.origin.y}, " + # f"doc={self.impl.native.documentView.frame.size.width}x{self.impl.native.documentView.frame.size.height}" + # f" @ {self.impl.native.documentView.frame.origin.x}x{self.impl.native.documentView.frame.origin.y}, " + # f"content={self.impl.native.contentView.frame.size.width}x{self.impl.native.contentView.frame.size.height}" + # f" @ {self.impl.native.contentView.frame.origin.x}x{self.impl.native.contentView.frame.origin.y}, " + # ) + self.interface.on_scroll(None) + + class ScrollContainer(Widget): def create(self): - self.native = NSScrollView.alloc().init() + self.native = TogaScrollView.alloc().init() + self.native.interface = self.interface + self.native.impl = self + self.native.autohidesScrollers = True self.native.borderType = NSNoBorder self.native.backgroundColor = NSColor.windowBackgroundColor @@ -16,30 +46,48 @@ def create(self): self.native.translatesAutoresizingMaskIntoConstraints = False self.native.autoresizesSubviews = True + NSNotificationCenter.defaultCenter.addObserver( + self.native, + selector=SEL("didScroll:"), + name=NSScrollViewDidLiveScrollNotification, + object=self.native, + ) + NSNotificationCenter.defaultCenter.addObserver( + self.native, + selector=SEL("didScroll:"), + name=NSScrollViewDidEndLiveScrollNotification, + object=self.native, + ) + # Add the layout constraints self.add_constraints() def set_content(self, widget): - self.native.documentView = widget.native - widget.viewport = CocoaViewport(self.native.documentView) + if widget: + self.native.documentView = widget._impl.native + widget._impl.viewport = CocoaViewport(self.native.documentView) - for child in widget.interface.children: - child._impl.container = widget + for child in widget.children: + child._impl.container = widget._impl + else: + self.native.documentView = None def set_bounds(self, x, y, width, height): + # print("SET BOUNDS", x, y, width, height) super().set_bounds(x, y, width, height) - # Restrict dimensions of content to dimensions of ScrollContainer # along any non-scrolling directions. Set dimensions of content # to its layout dimensions along the scrolling directions. - if self.interface.horizontal: width = self.interface.content.layout.width if self.interface.vertical: height = self.interface.content.layout.height - self.interface.content._impl.native.frame = NSMakeRect(0, 0, width, height) + self.native.documentView.frame = NSMakeRect(0, 0, width, height) + + def get_vertical(self): + return self.native.hasVerticalScroller def set_vertical(self, value): self.native.hasVerticalScroller = value @@ -48,6 +96,9 @@ def set_vertical(self, value): if self.interface.content: self.interface.refresh() + def get_horizontal(self): + return self.native.hasHorizontalScroller + def set_horizontal(self, value): self.native.hasHorizontalScroller = value # If the scroll container has content, we need to force a refresh @@ -59,27 +110,54 @@ def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - def set_on_scroll(self, on_scroll): - self.interface.factory.not_implemented("ScrollContainer.set_on_scroll()") + def get_max_vertical_position(self): + return max( + 0, + self.native.documentView.bounds.size.height + - self.native.contentView.bounds.size.height, + ) def get_vertical_position(self): - self.interface.factory.not_implemented( - "ScrollContainer.get_vertical_position()" - ) - return 0 + return self.native.contentView.bounds.origin.y def set_vertical_position(self, vertical_position): - self.interface.factory.not_implemented( - "ScrollContainer.set_vertical_position()" + if vertical_position < 0: + vertical_position = 0 + else: + max_value = self.get_max_vertical_position() + if vertical_position > max_value: + vertical_position = max_value + + new_position = NSMakePoint( + self.native.contentView.bounds.origin.x, + vertical_position, + ) + self.native.contentView.scrollToPoint(new_position) + self.native.reflectScrolledClipView(self.native.contentView) + self.interface.on_scroll(None) + + def get_max_horizontal_position(self): + return max( + 0, + self.native.documentView.bounds.size.width + - self.native.contentView.bounds.size.width, ) def get_horizontal_position(self): - self.interface.factory.not_implemented( - "ScrollContainer.get_horizontal_position()" - ) - return 0 + return self.native.contentView.bounds.origin.x def set_horizontal_position(self, horizontal_position): - self.interface.factory.not_implemented( - "ScrollContainer.set_horizontal_position()" + if horizontal_position < 0: + horizontal_position = 0 + else: + max_value = self.get_max_horizontal_position() + if horizontal_position > max_value: + horizontal_position = max_value + + new_position = NSMakePoint( + horizontal_position, + self.native.contentView.bounds.origin.y, ) + self.native.contentView.scrollToPoint(new_position) + self.native.reflectScrolledClipView(self.native.contentView) + self.interface.on_scroll(None) diff --git a/cocoa/tests_backend/widgets/scrollcontainer.py b/cocoa/tests_backend/widgets/scrollcontainer.py new file mode 100644 index 0000000000..87612c16cd --- /dev/null +++ b/cocoa/tests_backend/widgets/scrollcontainer.py @@ -0,0 +1,23 @@ +from toga_cocoa.libs import NSScrollView + +from .base import SimpleProbe + + +class ScrollContainerProbe(SimpleProbe): + native_class = NSScrollView + + @property + def has_content(self): + return self.native.documentView is not None + + @property + def document_height(self): + return self.widget.content._impl.native.bounds.size.height + + @property + def document_width(self): + return self.widget.content._impl.native.bounds.size.width + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index 6974ad57c1..d4d4318127 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -60,6 +60,23 @@ def window(self, window): if self._content: self._content.window = window + @property + def enabled(self) -> bool: + """Is the widget currently enabled? i.e., can the user interact with the widget? + + ScrollContainer widgets cannot be disabled; this property will always return + True; any attempt to modify it will be ignored. + """ + return True + + @enabled.setter + def enabled(self, value): + pass + + def focus(self): + "No-op; ScrollContainer cannot accept input focus" + pass + @property def content(self) -> Widget: """The root content widget displayed inside the scroll container.""" @@ -75,12 +92,11 @@ def content(self, widget): widget.app = self.app widget.window = self.window - self._content = widget - self._impl.set_content(widget._impl) + self._impl.set_content(widget) else: - self._content = None self._impl.set_content(None) + self._content = widget self.refresh() def refresh_sublayouts(self): @@ -118,15 +134,28 @@ def on_scroll(self, on_scroll): self._on_scroll = wrapped_handler(self, on_scroll) @property - def horizontal_position(self) -> float: + def max_horizontal_position(self) -> int | None: + """The maximum horizontal scroller position. + + Returns ``None`` if horizontal scrolling is disabled. + """ + if not self.horizontal: + return None + return self._impl.get_max_horizontal_position() + + @property + def horizontal_position(self) -> int | None: """The current horizontal scroller position. - Raises :any:`ValueError` if horizontal scrolling is not enabled. + If the value provided is outside the current range of the + scroller, the value will be clipped. + + If horizontal scrolling is disabled, returns ``None`` as the + current position, and raises :any:`ValueError` if an attempt + is made to change the position. """ if not self.horizontal: - raise ValueError( - "Cannot get horizontal position when horizontal is not set." - ) + return None return self._impl.get_horizontal_position() @horizontal_position.setter @@ -135,20 +164,35 @@ def horizontal_position(self, horizontal_position): raise ValueError( "Cannot set horizontal position when horizontal is not set." ) - self._impl.set_horizontal_position(float(horizontal_position)) + self._impl.set_horizontal_position(int(horizontal_position)) + + @property + def max_vertical_position(self) -> int | None: + """The maximum vertical scroller position. + + Returns ``None`` if vertical scrolling is disabled. + """ + if not self.vertical: + return None + return self._impl.get_max_vertical_position() @property - def vertical_position(self) -> float: + def vertical_position(self) -> int | None: """The current vertical scroller position. - Raises :any:`ValueError` if vertical scrolling is not enabled. + If the value provided is outside the current range of the + scroller, the value will be clipped. + + If vertical scrolling is disabled, returns ``None`` as the + current position, and raises :any:`ValueError` if an attempt + is made to change the position. """ if not self.vertical: - raise ValueError("Cannot get vertical position when vertical is not set.") + return None return self._impl.get_vertical_position() @vertical_position.setter def vertical_position(self, vertical_position): if not self.vertical: raise ValueError("Cannot set vertical position when vertical is not set.") - self._impl.set_vertical_position(float(vertical_position)) + self._impl.set_vertical_position(int(vertical_position)) diff --git a/core/tests/widgets/test_scrollcontainer.py b/core/tests/widgets/test_scrollcontainer.py index 2c949aa4b4..bd785d349b 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -5,6 +5,7 @@ import toga from toga_dummy.utils import ( EventLog, + assert_action_not_performed, assert_action_performed, assert_action_performed_with, ) @@ -48,7 +49,7 @@ def test_widget_created(): def test_widget_created_with_values(content, on_scroll_handler): - "A scroll container can be created with no arguments" + "A scroll container can be created with arguments" scroll_container = toga.ScrollContainer( content=content, on_scroll=on_scroll_handler, @@ -64,12 +65,12 @@ def test_widget_created_with_values(content, on_scroll_handler): assert scroll_container.on_scroll._raw == on_scroll_handler # The content has been assigned to the widget - assert_action_performed_with(scroll_container, "set content", widget=content._impl) + assert_action_performed_with(scroll_container, "set content", widget=content) # The scroll container has been refreshed assert_action_performed(scroll_container, "refresh") - # The content has been refreshed + # The content and the frame has been refreshed assert_action_performed(content, "refresh") # The scroll handler hasn't been invoked @@ -91,7 +92,7 @@ def test_assign_to_app(app, scroll_container, content): def test_assign_to_app_no_content(app): - """If the widget is assigned to an app, and there is no content, nothing happens""" + """If the widget is assigned to an app, and there is no content, there's no error""" scroll_container = toga.ScrollContainer() # Scroll container is initially unassigned @@ -119,7 +120,7 @@ def test_assign_to_window(window, scroll_container, content): def test_assign_to_window_no_content(window): - """If the widget is assigned to an app, and there is no content, nothing happens""" + """If the widget is assigned to an app, and there is no content, there's no error""" scroll_container = toga.ScrollContainer() # Scroll container is initially unassigned @@ -132,6 +133,25 @@ def test_assign_to_window_no_content(window): assert scroll_container.window == window +def test_disable_no_op(scroll_container): + "ScrollContainer doesn't have a disabled state" + # Enabled by default + assert scroll_container.enabled + + # Try to disable the widget + scroll_container.enabled = False + + # Still enabled. + assert scroll_container.enabled + + +def test_focus_noop(scroll_container): + "Focus is a no-op." + + scroll_container.focus() + assert_action_not_performed(scroll_container, "focus") + + def test_set_content(app, window, scroll_container, content): """The content of the scroll container can be changed""" # Assign the scroll container to an app and window @@ -150,11 +170,7 @@ def test_set_content(app, window, scroll_container, content): scroll_container.content = new_content # The content has been assigned to the widget - assert_action_performed_with( - scroll_container, - "set content", - widget=new_content._impl, - ) + assert_action_performed_with(scroll_container, "set content", widget=new_content) # The scroll container has been refreshed assert_action_performed(scroll_container, "refresh") @@ -191,11 +207,7 @@ def test_clear_content(app, window, scroll_container, content): scroll_container.content = None # The content has been assigned to the widget - assert_action_performed_with( - scroll_container, - "set content", - widget=None, - ) + assert_action_performed_with(scroll_container, "set content", widget=None) # The scroll container has been refreshed assert_action_performed(scroll_container, "refresh") @@ -208,45 +220,6 @@ def test_clear_content(app, window, scroll_container, content): assert content.window is None -def test_content(app, window, scroll_container, content): - """The content of the scroll container can be changed""" - # Assign the scroll container to an app and window - scroll_container.app = app - scroll_container.window = window - - # The content is also assigned - assert content.app == app - assert content.window == window - - # Reset the event log - EventLog.reset() - - # Create new content, and assign it to the scroll container - new_content = toga.Box() - scroll_container.content = new_content - - # The content has been assigned to the widget - assert_action_performed_with( - scroll_container, - "set content", - widget=new_content._impl, - ) - - # The new content has been refreshed - assert_action_performed(new_content, "refresh") - - # The content has been assigned - assert scroll_container.content == new_content - - # The new content is assigned to - assert new_content.app == app - assert new_content.window == window - - # The old content isn't - assert content.app is None - assert content.window is None - - @pytest.mark.parametrize( "value, expected", [ @@ -296,19 +269,18 @@ def test_horizontal_position(scroll_container): scroll_container.horizontal_position = 10 assert scroll_container.horizontal_position == 10 + assert scroll_container.max_horizontal_position == 1000 def test_get_horizontal_position_when_not_horizontal(scroll_container): "If horizontal scrolling isn't enabled, getting the horizontal position raises an error" scroll_container.horizontal = False - with pytest.raises( - ValueError, - match=r"Cannot get horizontal position when horizontal is not set.", - ): - scroll_container.horizontal_position + + assert scroll_container.horizontal_position is None + assert scroll_container.max_horizontal_position is None -def test_set_horizontal_position_when_not_horizontal(scroll_container): +def test_horizontal_position_when_not_horizontal(scroll_container): "If horizontal scrolling isn't enabled, setting the horizontal position raises an error" scroll_container.horizontal = False with pytest.raises( @@ -323,16 +295,15 @@ def test_vertical_position(scroll_container): scroll_container.vertical_position = 10 assert scroll_container.vertical_position == 10 + assert scroll_container.max_vertical_position == 2000 def test_get_vertical_position_when_not_vertical(scroll_container): "If vertical scrolling isn't enabled, getting the vertical position raises an error" scroll_container.vertical = False - with pytest.raises( - ValueError, - match=r"Cannot get vertical position when vertical is not set.", - ): - scroll_container.vertical_position + + assert scroll_container.vertical_position is None + assert scroll_container.max_vertical_position is None def test_set_vertical_position_when_not_vertical(scroll_container): diff --git a/dummy/src/toga_dummy/widgets/scrollcontainer.py b/dummy/src/toga_dummy/widgets/scrollcontainer.py index 969f4dd5ae..c52cd5e506 100644 --- a/dummy/src/toga_dummy/widgets/scrollcontainer.py +++ b/dummy/src/toga_dummy/widgets/scrollcontainer.py @@ -28,13 +28,33 @@ def set_on_scroll(self, on_scroll): self._set_value("on_scroll", on_scroll) def set_horizontal_position(self, horizontal_position): + if horizontal_position < 0: + horizontal_position = 0 + elif horizontal_position > self.get_max_horizontal_position(): + horizontal_position = self.get_max_horizontal_position() + self._set_value("horizontal_position", horizontal_position) def get_horizontal_position(self): + if not self.get_horizontal(): + return None return self._get_value("horizontal_position", 0) + def get_max_horizontal_position(self): + return 1000 + def set_vertical_position(self, vertical_position): + if vertical_position < 0: + vertical_position = 0 + elif vertical_position > self.get_max_vertical_position(): + vertical_position = self.get_max_vertical_position() + self._set_value("vertical_position", vertical_position) def get_vertical_position(self): + if not self.get_vertical(): + return None return self._get_value("vertical_position", 0) + + def get_max_vertical_position(self): + return 2000 diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py new file mode 100644 index 0000000000..a6c6e6a309 --- /dev/null +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -0,0 +1,218 @@ +import pytest + +import toga +from toga.colors import CORNFLOWERBLUE, REBECCAPURPLE +from toga.style.pack import COLUMN, ROW, Pack + +from .properties import ( # noqa: F401 + test_background_color, + test_background_color_reset, + test_background_color_transparent, + test_enable_noop, + test_flex_widget_size, + test_focus_noop, +) + + +@pytest.fixture +async def content(): + box = toga.Box( + children=[ + toga.Label( + f"I am line {i}", + style=Pack( + padding=20, + height=20, + width=160, + background_color=CORNFLOWERBLUE if i % 2 else REBECCAPURPLE, + ), + ) + for i in range(0, 100) + ], + style=Pack(direction=COLUMN), + ) + return box + + +@pytest.fixture +async def small_content(): + box = toga.Box( + children=[ + toga.Label( + "I am content", + style=Pack( + padding=20, + height=20, + width=160, + background_color=CORNFLOWERBLUE, + ), + ) + ], + style=Pack(direction=COLUMN), + ) + return box + + +@pytest.fixture +async def widget(content): + return toga.ScrollContainer(content=content, style=Pack(flex=1)) + + +async def test_clear_content(widget, probe, small_content): + "Widget content can be cleared and reset" + assert probe.document_width == probe.width + assert probe.document_height == 6000 + + widget.content = None + await probe.redraw("Widget content has been cleared") + assert not probe.has_content + + widget.content = None + await probe.redraw("Widget content has been re-cleared") + assert not probe.has_content + + widget.content = small_content + await probe.redraw("Widget content has been restored") + assert probe.has_content + assert probe.document_width == probe.width + assert probe.document_height == probe.height + + +async def test_enable_horizontal_scrolling(widget, probe, content): + "Horizontal scrolling can be disabled" + content.style.direction = ROW + + widget.horizontal = False + await probe.redraw("Horizontal scrolling is disabled") + + assert widget.horizontal_position is None + assert widget.max_horizontal_position is None + with pytest.raises(ValueError): + widget.horizontal_position = 120 + + widget.horizontal = True + await probe.redraw("Horizontal scrolling is enabled") + + widget.horizontal_position = 120 + await probe.redraw("Horizontal scroll was allowed") + assert widget.horizontal_position == 120 + + +async def test_enable_vertical_scrolling(widget, probe): + widget.vertical = False + await probe.redraw("Vertical scrolling is disabled") + + assert widget.vertical_position is None + assert widget.max_vertical_position is None + with pytest.raises(ValueError): + widget.vertical_position = 120 + + widget.vertical = True + await probe.redraw("Vertical scrolling is enabled") + + widget.vertical_position = 120 + await probe.redraw("Vertical scroll was allowed") + assert widget.vertical_position == 120 + + +async def test_vertical_scroll(widget, probe): + "The widget can be scrolled vertically." + assert probe.document_width == probe.width + assert probe.document_height == 6000 + + assert widget.max_horizontal_position == 0 + assert widget.max_vertical_position == 6000 - probe.height + + assert widget.horizontal_position == 0 + assert widget.vertical_position == 0 + + widget.vertical_position = probe.height * 3 + await probe.redraw("Scroll down 3 pages") + assert widget.vertical_position == probe.height * 3 + + widget.vertical_position = 0 + await probe.redraw("Scroll back to origin") + assert widget.vertical_position == 0 + + widget.vertical_position = 10000 + await probe.redraw("Scroll past the end") + assert widget.vertical_position == widget.max_vertical_position + + widget.vertical_position = -100 + await probe.redraw("Scroll past the start") + assert widget.vertical_position == 0 + + +async def test_vertical_scroll_small_content(widget, probe, small_content): + "The widget can be scrolled vertically when the content doesn't need scrolling." + widget.content = small_content + + assert probe.document_width == probe.width + assert probe.document_height == probe.height + + assert widget.max_horizontal_position == 0 + assert widget.max_vertical_position == 0 + + assert widget.horizontal_position == 0 + assert widget.vertical_position == 0 + + widget.vertical_position = probe.height * 3 + await probe.redraw("Scroll down 3 pages") + assert widget.vertical_position == 0 + + widget.vertical_position = 0 + await probe.redraw("Scroll back to origin") + assert widget.vertical_position == 0 + + +async def test_horizontal_scroll(widget, probe, content): + "The widget can be scrolled horizontally." + content.style.direction = ROW + + assert probe.document_width == 20000 + assert probe.document_height == probe.height + + assert widget.max_horizontal_position == 20000 - probe.width + assert widget.max_vertical_position == 0 + + assert widget.horizontal_position == 0 + assert widget.vertical_position == 0 + + widget.horizontal_position = probe.height * 3 + await probe.redraw("Scroll down 3 pages") + assert widget.horizontal_position == probe.height * 3 + + widget.horizontal_position = 0 + await probe.redraw("Scroll back to origin") + assert widget.horizontal_position == 0 + + widget.horizontal_position = 30000 + await probe.redraw("Scroll past the end") + assert widget.horizontal_position == widget.max_horizontal_position + + widget.horizontal_position = -100 + await probe.redraw("Scroll past the start") + assert widget.horizontal_position == 0 + + +async def test_horizontal_scroll_small_content(widget, probe, small_content): + "The widget can be scrolled horizontally when the content doesn't need scrolling." + small_content.style.direction = ROW + widget.content = small_content + + assert probe.document_width == probe.width + assert probe.document_height == probe.height + + assert widget.max_horizontal_position == 0 + assert widget.max_vertical_position == 0 + + assert widget.horizontal_position == 0 + assert widget.vertical_position == 0 + + widget.horizontal_position = probe.height * 3 + await probe.redraw("Scroll down 3 pages") + assert widget.horizontal_position == 0 + + widget.horizontal_position = 0 + await probe.redraw("Scroll back to origin") + assert widget.horizontal_position == 0 From d72cfe591e1c6099a77f87097e62438c0f372d62 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 10 Jun 2023 13:50:32 +0800 Subject: [PATCH 05/40] Tweaked scrollcontainer example. --- .../scrollcontainer/scrollcontainer/app.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/examples/scrollcontainer/scrollcontainer/app.py b/examples/scrollcontainer/scrollcontainer/app.py index 6ffa154251..3db45fd25f 100644 --- a/examples/scrollcontainer/scrollcontainer/app.py +++ b/examples/scrollcontainer/scrollcontainer/app.py @@ -22,12 +22,8 @@ class ScrollContainerApp(toga.App): scroller = None def startup(self): - box = toga.Box() - box.style.direction = COLUMN - box.style.padding = 10 - self.scroller = toga.ScrollContainer( - horizontal=self.hscrolling, vertical=self.vscrolling - ) + main_box = toga.Box(style=Pack(direction=COLUMN)) + switch_box = toga.Box(style=Pack(direction=ROW)) switch_box.add( toga.Switch( @@ -43,17 +39,26 @@ def startup(self): on_change=self.handle_hscrolling, ) ) - box.add(switch_box) + main_box.add(switch_box) + + box = toga.Box(style=Pack(direction=COLUMN, padding=10)) + self.scroller = toga.ScrollContainer( + horizontal=self.hscrolling, + vertical=self.vscrolling, + style=Pack(flex=1), + ) for x in range(100): label_text = f"Label {x}" box.add(Item(label_text)) self.scroller.content = box + main_box.add(self.scroller) self.main_window = toga.MainWindow(self.name, size=(400, 700)) - self.main_window.content = self.scroller + self.main_window.content = main_box self.main_window.show() + self.commands.add( toga.Command( self.toggle_up, From 04619a98143a2dc69319dccab53d94c769a817fb Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 10 Jun 2023 13:50:58 +0800 Subject: [PATCH 06/40] Misc debug cleanups. --- cocoa/src/toga_cocoa/widgets/base.py | 2 +- cocoa/src/toga_cocoa/widgets/multilinetextinput.py | 5 ++++- core/src/toga/style/pack.py | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/base.py b/cocoa/src/toga_cocoa/widgets/base.py index 78963b3f78..273a3954dd 100644 --- a/cocoa/src/toga_cocoa/widgets/base.py +++ b/cocoa/src/toga_cocoa/widgets/base.py @@ -34,7 +34,7 @@ def container(self): @container.setter def container(self, container): if self.container: - assert container is None, "Widget already has a container" + assert container is None, f"Widget {self} already has a container" # Existing container should be removed self.constraints.container = None diff --git a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py index b4d59e26cd..478532a459 100644 --- a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py +++ b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py @@ -1,3 +1,4 @@ +from rubicon.objc import objc_method, objc_property from travertino.size import at_least from toga.colors import TRANSPARENT @@ -9,13 +10,15 @@ NSTextView, NSViewHeightSizable, NSViewWidthSizable, - objc_method, ) from .base import Widget class TogaTextView(NSTextView): + interface = objc_property(object, weak=True) + impl = objc_property(object, weak=True) + @objc_method def textDidChange_(self, notification) -> None: self.interface.on_change(None) diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index 824aabf761..136bfeec0f 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -126,6 +126,7 @@ def apply(self, prop, value): def layout(self, node, viewport): # Precompute `scale_factor` by providing it as a default param. # self._debug("=" * 80) + # self._debug(f"Layout root {node}, available {viewport.width}x{viewport.height}") self.__class__._depth = -1 def scale(value, scale_factor=viewport.dpi / viewport.baseline_dpi): From b89f14a14c183fe2e93aae90f0b730b3ebf79023 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 11 Jun 2023 11:05:51 +0800 Subject: [PATCH 07/40] Cocoa ScrollContainer at 100%, including container rework. --- cocoa/src/toga_cocoa/constraints.py | 11 +- cocoa/src/toga_cocoa/containers.py | 104 ++++++++++ cocoa/src/toga_cocoa/libs/foundation.py | 5 + cocoa/src/toga_cocoa/widgets/base.py | 14 +- cocoa/src/toga_cocoa/widgets/box.py | 9 +- .../src/toga_cocoa/widgets/optioncontainer.py | 15 +- .../src/toga_cocoa/widgets/scrollcontainer.py | 57 +++--- .../src/toga_cocoa/widgets/splitcontainer.py | 16 +- cocoa/src/toga_cocoa/window.py | 81 ++------ cocoa/tests_backend/widgets/base.py | 3 +- .../tests_backend/widgets/scrollcontainer.py | 29 ++- core/src/toga/widgets/base.py | 14 +- core/src/toga/widgets/scrollcontainer.py | 44 ++--- core/tests/widgets/test_base.py | 183 +++++++++++++----- core/tests/widgets/test_scrollcontainer.py | 18 +- .../api/containers/scrollcontainer.rst | 4 +- docs/reference/api/index.rst | 4 +- docs/reference/data/widgets_by_platform.csv | 2 +- dummy/src/toga_dummy/widgets/base.py | 9 +- .../src/toga_dummy/widgets/scrollcontainer.py | 12 +- dummy/src/toga_dummy/window.py | 24 ++- testbed/tests/widgets/test_scrollcontainer.py | 129 +++++++++++- 22 files changed, 534 insertions(+), 253 deletions(-) create mode 100644 cocoa/src/toga_cocoa/containers.py diff --git a/cocoa/src/toga_cocoa/constraints.py b/cocoa/src/toga_cocoa/constraints.py index d024092245..50d8aa1daa 100644 --- a/cocoa/src/toga_cocoa/constraints.py +++ b/cocoa/src/toga_cocoa/constraints.py @@ -100,10 +100,9 @@ def container(self, value): self.container.native.addConstraint(self.height_constraint) def update(self, x, y, width, height): - if self.container: - # print(f"UPDATE CONSTRAINTS {self.widget} in {self.container} {width}x{height}@{x},{y}") - self.left_constraint.constant = x - self.top_constraint.constant = 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 - self.width_constraint.constant = width - self.height_constraint.constant = height + self.width_constraint.constant = width + self.height_constraint.constant = height diff --git a/cocoa/src/toga_cocoa/containers.py b/cocoa/src/toga_cocoa/containers.py new file mode 100644 index 0000000000..6cf5695e98 --- /dev/null +++ b/cocoa/src/toga_cocoa/containers.py @@ -0,0 +1,104 @@ +from rubicon.objc import objc_method + +from .libs import ( + NSLayoutAttributeBottom, + NSLayoutAttributeLeft, + NSLayoutAttributeRight, + NSLayoutAttributeTop, + NSLayoutConstraint, + NSLayoutRelationGreaterThanOrEqual, + NSView, +) + + +class TogaView(NSView): + @objc_method + def isFlipped(self) -> bool: + # Default Cocoa coordinate frame is around the wrong way. + return True + + +class BaseContainer: + def __init__(self, on_refresh=None): + self.on_refresh = on_refresh + # macOS always renders at 96dpi. Scaling is handled + # transparently at the level of the screen compositor. + self.dpi = 96 + self.baseline_dpi = self.dpi + + def refreshed(self): + if self.on_refresh: + self.on_refresh() + + +class MinimumContainer(BaseContainer): + def __init__(self): + """A container for evaluating the minumum possible size for a layout""" + super().__init__() + self.width = 0 + self.height = 0 + + +class Container(BaseContainer): + def __init__( + self, + min_width=100, + min_height=100, + layout_native=None, + on_refresh=None, + ): + """ + :param min_width: The minimum width to enforce on the container + :param min_height: The minimum height to enforce on the container + :param layout_native: The native widget that should be used to provide + size hints to the layout. This will usually be the container widget + itself; however, for widgets like ScrollContainer where the layout + needs to be computed based on a different size to what will be + rendered, the source of the size can be different. + :param on_refresh: The callback to be notified when this container's + layout is refreshed. + """ + super().__init__(on_refresh=on_refresh) + self.native = TogaView.alloc().init() + self.layout_native = self.native if layout_native is None else layout_native + + # Enforce a minimum size based on the content size. + # This is enforcing the *minimum* size; the container might actually be + # bigger. If the window is resizable, using >= allows the window to + # be dragged larger; if not resizable, it enforces the smallest + # size that can be programmatically set on the window. + self._min_width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 + self.native, + NSLayoutAttributeRight, + NSLayoutRelationGreaterThanOrEqual, + self.native, + NSLayoutAttributeLeft, + 1.0, + min_width, + ) + self.native.addConstraint(self._min_width_constraint) + + self._min_height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 + self.native, + NSLayoutAttributeBottom, + NSLayoutRelationGreaterThanOrEqual, + self.native, + NSLayoutAttributeTop, + 1.0, + min_height, + ) + self.native.addConstraint(self._min_height_constraint) + + @property + def width(self): + return self.layout_native.frame.size.width + + @property + def height(self): + return self.layout_native.frame.size.height + + def set_min_width(self, width): + self._min_width_constraint.constant = width + + def set_min_height(self, height): + self._min_height_constraint.constant = height diff --git a/cocoa/src/toga_cocoa/libs/foundation.py b/cocoa/src/toga_cocoa/libs/foundation.py index 650ee4c394..ffbf612f60 100644 --- a/cocoa/src/toga_cocoa/libs/foundation.py +++ b/cocoa/src/toga_cocoa/libs/foundation.py @@ -35,6 +35,11 @@ NSNotification = ObjCClass("NSNotification") NSNotification.declare_property("object") +###################################################################### +# NSRunLoop.h +NSRunLoop = ObjCClass("NSRunLoop") +NSRunLoop.declare_class_property("currentRunLoop") + ###################################################################### # NSURL.h NSURL = ObjCClass("NSURL") diff --git a/cocoa/src/toga_cocoa/widgets/base.py b/cocoa/src/toga_cocoa/widgets/base.py index 273a3954dd..2036860438 100644 --- a/cocoa/src/toga_cocoa/widgets/base.py +++ b/cocoa/src/toga_cocoa/widgets/base.py @@ -34,7 +34,7 @@ def container(self): @container.setter def container(self, container): if self.container: - assert container is None, f"Widget {self} already has a container" + assert container is None, f"{self} already has a container" # Existing container should be removed self.constraints.container = None @@ -53,11 +53,7 @@ def container(self, container): @property def viewport(self): - return self._viewport - - @viewport.setter - def viewport(self, viewport): - self._viewport = viewport + return self._container def get_enabled(self): return self.native.isEnabled @@ -106,11 +102,7 @@ def set_tab_index(self, tab_index): # INTERFACE def add_child(self, child): - if self.viewport: - # we are the top level NSView - child.container = self - else: - child.container = self.container + child.container = self.container def insert_child(self, index, child): self.add_child(child) diff --git a/cocoa/src/toga_cocoa/widgets/box.py b/cocoa/src/toga_cocoa/widgets/box.py index df5ebec834..5ab2174cf2 100644 --- a/cocoa/src/toga_cocoa/widgets/box.py +++ b/cocoa/src/toga_cocoa/widgets/box.py @@ -1,17 +1,10 @@ from travertino.size import at_least -from toga_cocoa.libs import NSView, objc_method +from toga_cocoa.containers import TogaView from .base import Widget -class TogaView(NSView): - @objc_method - def isFlipped(self) -> bool: - # Default Cocoa coordinate frame is around the wrong way. - return True - - class Box(Widget): def create(self): self.native = TogaView.alloc().init() diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index c3951b4604..6bc9314f13 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -1,7 +1,8 @@ +from rubicon.objc import objc_method from travertino.size import at_least -from toga_cocoa.libs import NSObject, NSTabView, NSTabViewItem, objc_method -from toga_cocoa.window import CocoaViewport +from toga_cocoa.containers import Container +from toga_cocoa.libs import NSObject, NSTabView, NSTabViewItem from ..libs import objc_property from .base import Widget @@ -52,10 +53,8 @@ def add_content(self, index, text, widget): text (str): The text for the option container widget: The widget or widget tree that belongs to the text. """ - widget.viewport = CocoaViewport(widget.native) - - for child in widget.interface.children: - child._impl.container = widget + container = Container() + widget.container = container item = NSTabViewItem.alloc().init() item.label = text @@ -63,9 +62,9 @@ def add_content(self, index, text, widget): # Turn the autoresizing mask on the widget widget # into constraints. This makes the widget fill the # available space inside the OptionContainer. - widget.native.translatesAutoresizingMaskIntoConstraints = True + container.native.translatesAutoresizingMaskIntoConstraints = True - item.view = widget.native + item.view = container.native self.native.insertTabViewItem(item, atIndex=index) def remove_content(self, index): diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index f7015cdf07..c7571c75aa 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -1,17 +1,18 @@ from rubicon.objc import SEL, objc_method, objc_property from travertino.size import at_least +from toga_cocoa.containers import Container from toga_cocoa.libs import ( NSColor, NSMakePoint, NSMakeRect, NSNoBorder, NSNotificationCenter, + NSRunLoop, NSScrollView, NSScrollViewDidEndLiveScrollNotification, NSScrollViewDidLiveScrollNotification, ) -from toga_cocoa.window import CocoaViewport from .base import Widget @@ -22,14 +23,6 @@ class TogaScrollView(NSScrollView): @objc_method def didScroll_(self, note) -> None: - # print( - # f"SCROLL frame={self.impl.native.frame.size.width}x{self.impl.native.frame.size.height}" - # f" @ {self.impl.native.frame.origin.x}x{self.impl.native.frame.origin.y}, " - # f"doc={self.impl.native.documentView.frame.size.width}x{self.impl.native.documentView.frame.size.height}" - # f" @ {self.impl.native.documentView.frame.origin.x}x{self.impl.native.documentView.frame.origin.y}, " - # f"content={self.impl.native.contentView.frame.size.width}x{self.impl.native.contentView.frame.size.height}" - # f" @ {self.impl.native.contentView.frame.origin.x}x{self.impl.native.contentView.frame.origin.y}, " - # ) self.interface.on_scroll(None) @@ -43,8 +36,14 @@ def create(self): self.native.borderType = NSNoBorder self.native.backgroundColor = NSColor.windowBackgroundColor - self.native.translatesAutoresizingMaskIntoConstraints = False - self.native.autoresizesSubviews = True + # The container for the document bases its layout on the + # size of the content view. It can only exceed the size + # of the contentView if scrolling is enabled in that axis. + self.document_container = Container( + layout_native=self.native.contentView, + on_refresh=self.content_refreshed, + ) + self.native.documentView = self.document_container.native NSNotificationCenter.defaultCenter.addObserver( self.native, @@ -63,26 +62,36 @@ def create(self): self.add_constraints() def set_content(self, widget): - if widget: - self.native.documentView = widget._impl.native - widget._impl.viewport = CocoaViewport(self.native.documentView) + # If there's existing content, clear its container + if self.interface.content: + self.interface.content._impl.container = None - for child in widget.children: - child._impl.container = widget._impl - else: - self.native.documentView = None + # If there's new content, set the container of the content + if widget: + widget.container = self.document_container def set_bounds(self, x, y, width, height): - # print("SET BOUNDS", x, y, width, height) super().set_bounds(x, y, width, height) - # Restrict dimensions of content to dimensions of ScrollContainer - # along any non-scrolling directions. Set dimensions of content - # to its layout dimensions along the scrolling directions. + + # Setting the bounds changes the constraints, but that doesn't mean + # the constraints have been fully applied. Let the NSRunLoop tick once + # to ensure constraints are applied. + NSRunLoop.currentRunLoop.runUntilDate(None) + + # Now that we have an updated size for the ScrollContainer, re-evaluate + # the size of the document content + if self.interface._content: + self.interface._content.refresh() + + def content_refreshed(self): + width = self.native.frame.size.width + height = self.native.frame.size.height + if self.interface.horizontal: - width = self.interface.content.layout.width + width = max(self.interface.content.layout.width, width) if self.interface.vertical: - height = self.interface.content.layout.height + height = max(self.interface.content.layout.height, height) self.native.documentView.frame = NSMakeRect(0, 0, width, height) diff --git a/cocoa/src/toga_cocoa/widgets/splitcontainer.py b/cocoa/src/toga_cocoa/widgets/splitcontainer.py index 9410925132..385bde9816 100644 --- a/cocoa/src/toga_cocoa/widgets/splitcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/splitcontainer.py @@ -1,9 +1,9 @@ +from rubicon.objc import objc_method, objc_property from travertino.size import at_least -from toga_cocoa.libs import NSObject, NSSize, NSSplitView, objc_method -from toga_cocoa.window import CocoaViewport +from toga_cocoa.containers import Container +from toga_cocoa.libs import NSObject, NSSize, NSSplitView -from ..libs import objc_property from .base import Widget @@ -64,17 +64,15 @@ def create(self): def add_content(self, position, widget, flex): # TODO: add flex option to the implementation - widget.viewport = CocoaViewport(widget.native) - - for child in widget.interface.children: - child._impl.container = widget + container = Container() + widget.container = container # Turn the autoresizing mask on the widget into constraints. # This makes the widget fill the available space inside the # SplitContainer. # FIXME Use Constraints to enforce min width and height of the widgets otherwise width of 0 is possible. - widget.native.translatesAutoresizingMaskIntoConstraints = True - self.native.addSubview(widget.native) + container.native.translatesAutoresizingMaskIntoConstraints = True + self.native.addSubview(container.native) def set_direction(self, value): self.native.vertical = value diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index da10931b2f..5bdf9239ad 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -1,14 +1,9 @@ from toga.command import Command as BaseCommand +from toga_cocoa.containers import Container, MinimumContainer from toga_cocoa.libs import ( SEL, NSBackingStoreBuffered, NSClosableWindowMask, - NSLayoutAttributeBottom, - NSLayoutAttributeLeft, - NSLayoutAttributeRight, - NSLayoutAttributeTop, - NSLayoutConstraint, - NSLayoutRelationGreaterThanOrEqual, NSMakeRect, NSMiniaturizableWindowMask, NSMutableArray, @@ -30,24 +25,6 @@ def toolbar_identifier(cmd): return "ToolbarItem-%s" % id(cmd) -class CocoaViewport: - def __init__(self, view): - self.view = view - # macOS always renders at 96dpi. Scaling is handled - # transparently at the level of the screen compositor. - self.dpi = 96 - self.baseline_dpi = self.dpi - - @property - def width(self): - # If `view` is `None`, we'll treat this a 0x0 viewport. - return 0 if self.view is None else self.view.frame.size.width - - @property - def height(self): - return 0 if self.view is None else self.view.frame.size.height - - class WindowDelegate(NSObject): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) @@ -179,61 +156,29 @@ def __init__(self, interface, title, position, size): self.native.delegate = self.delegate + self.container = Container() + self.native.contentView = self.container.native + def create_toolbar(self): self._toolbar_items = {} for cmd in self.interface.toolbar: if isinstance(cmd, BaseCommand): self._toolbar_items[toolbar_identifier(cmd)] = cmd - self._toolbar_native = NSToolbar.alloc().initWithIdentifier_( + self._toolbar_native = NSToolbar.alloc().initWithIdentifier( "Toolbar-%s" % id(self) ) - self._toolbar_native.setDelegate_(self.delegate) + self._toolbar_native.setDelegate(self.delegate) - self.native.setToolbar_(self._toolbar_native) + self.native.setToolbar(self._toolbar_native) def clear_content(self): if self.interface.content: - for child in self.interface.content.children: - child._impl.container = None + self.interface.content._impl.container = None def set_content(self, widget): - # Set the window's view to be the widget's native object. - self.native.contentView = widget.native - - # Set the widget's viewport to be based on the window's content. - widget.viewport = CocoaViewport(view=widget.native) - - # Add all children to the content widget. - for child in widget.interface.children: - child._impl.container = widget - - # Enforce a minimum size based on the content size. - # This is enforcing the *minimum* size; the window might actually be - # bigger. If the window is resizable, using >= allows the window to - # be dragged larger; if not resizable, it enforces the smallest - # size that can be programmatically set on the window. - self._min_width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - widget.native, - NSLayoutAttributeRight, - NSLayoutRelationGreaterThanOrEqual, - widget.native, - NSLayoutAttributeLeft, - 1.0, - 100, - ) - widget.native.addConstraint(self._min_width_constraint) - - self._min_height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - widget.native, - NSLayoutAttributeBottom, - NSLayoutRelationGreaterThanOrEqual, - widget.native, - NSLayoutAttributeTop, - 1.0, - 100, - ) - widget.native.addConstraint(self._min_height_constraint) + # Set the new widget's container to be the window's container + widget.container = self.container def get_title(self): return str(self.native.title) @@ -293,10 +238,10 @@ def show(self): # a minimum window size. self.interface.content.style.layout( self.interface.content, - CocoaViewport(view=None), + MinimumContainer(), ) - self._min_width_constraint.constant = self.interface.content.layout.width - self._min_height_constraint.constant = self.interface.content.layout.height + self.container.set_min_width(self.interface.content.layout.width) + self.container.set_min_height(self.interface.content.layout.height) # Refresh with the actual viewport to do the proper rendering. self.interface.content.refresh() diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index f28aedbf6a..110a54553a 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -17,7 +17,8 @@ def __init__(self, widget): assert isinstance(self.native, self.native_class) def assert_container(self, container): - container_native = container._impl.native + assert container._impl.container == self.impl.container + container_native = container._impl.container.native for control in container_native.subviews: if control == self.native: break diff --git a/cocoa/tests_backend/widgets/scrollcontainer.py b/cocoa/tests_backend/widgets/scrollcontainer.py index 87612c16cd..1535880545 100644 --- a/cocoa/tests_backend/widgets/scrollcontainer.py +++ b/cocoa/tests_backend/widgets/scrollcontainer.py @@ -1,4 +1,10 @@ -from toga_cocoa.libs import NSScrollView +from toga_cocoa.libs import ( + NSMakePoint, + NSNotificationCenter, + NSScrollView, + NSScrollViewDidEndLiveScrollNotification, + NSScrollViewDidLiveScrollNotification, +) from .base import SimpleProbe @@ -8,15 +14,30 @@ class ScrollContainerProbe(SimpleProbe): @property def has_content(self): - return self.native.documentView is not None + return len(self.native.documentView.subviews) > 0 @property def document_height(self): - return self.widget.content._impl.native.bounds.size.height + return self.native.documentView.bounds.size.height @property def document_width(self): - return self.widget.content._impl.native.bounds.size.width + return self.native.documentView.bounds.size.width + + async def scroll(self): + self.native.contentView.scrollToPoint(NSMakePoint(0, 600)) + self.native.reflectScrolledClipView(self.native.contentView) + + # Send 2 scroll-in-progress, then one end-scroll message. + NSNotificationCenter.defaultCenter.postNotificationName( + NSScrollViewDidLiveScrollNotification, object=self.native + ) + NSNotificationCenter.defaultCenter.postNotificationName( + NSScrollViewDidLiveScrollNotification, object=self.native + ) + NSNotificationCenter.defaultCenter.postNotificationName( + NSScrollViewDidEndLiveScrollNotification, object=self.native + ) async def wait_for_scroll_completion(self): # No animation associated with scroll, so this is a no-op diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 67197d65c9..b0d2f8e1e5 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -117,8 +117,8 @@ def add(self, *children: list[Widget]): self._impl.add_child(child._impl) - if self.window: - self.window.content.refresh() + # Whatever layout we're a part of needs to be refreshed + self.refresh() def insert(self, index: int, child: Widget): """Insert a widget as a child of this widget. @@ -147,8 +147,8 @@ def insert(self, index: int, child: Widget): self._impl.insert_child(index, child._impl) - if self.window: - self.window.content.refresh() + # Whatever layout we're a part of needs to be refreshed + self.refresh() def remove(self, *children: list[Widget]): """Remove the provided widgets as children of this node. @@ -174,8 +174,9 @@ def remove(self, *children: list[Widget]): self._impl.remove_child(child._impl) - if self.window and removed: - self.window.content.refresh() + # If we removed something, whatever layout we're a part of needs to be refreshed + if removed: + self.refresh() def clear(self): """Remove all child widgets of this node. @@ -265,6 +266,7 @@ def refresh(self): # We can't compute a layout until we have a viewport if self._impl.viewport: super().refresh(self._impl.viewport) + self._impl.viewport.refreshed() def refresh_sublayouts(self): for child in self.children: diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index d4d4318127..7c4f89d2a8 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -6,9 +6,6 @@ class ScrollContainer(Widget): - MIN_WIDTH = 100 - MIN_HEIGHT = 100 - def __init__( self, id=None, @@ -92,18 +89,13 @@ def content(self, widget): widget.app = self.app widget.window = self.window - self._impl.set_content(widget) + self._impl.set_content(widget._impl) else: self._impl.set_content(None) self._content = widget self.refresh() - def refresh_sublayouts(self): - """Refresh the layout and appearance of this widget.""" - if self._content: - self._content.refresh() - @property def vertical(self) -> bool: """Is vertical scrolling enabled?""" @@ -112,7 +104,8 @@ def vertical(self) -> bool: @vertical.setter def vertical(self, value): self._impl.set_vertical(bool(value)) - self.refresh_sublayouts() + if self._content: + self._content.refresh() @property def horizontal(self) -> bool: @@ -122,7 +115,8 @@ def horizontal(self) -> bool: @horizontal.setter def horizontal(self, value): self._impl.set_horizontal(bool(value)) - self.refresh_sublayouts() + if self._content: + self._content.refresh() @property def on_scroll(self) -> callable: @@ -135,7 +129,7 @@ def on_scroll(self, on_scroll): @property def max_horizontal_position(self) -> int | None: - """The maximum horizontal scroller position. + """The maximum horizontal scroll position. Returns ``None`` if horizontal scrolling is disabled. """ @@ -145,14 +139,14 @@ def max_horizontal_position(self) -> int | None: @property def horizontal_position(self) -> int | None: - """The current horizontal scroller position. + """The current horizontal scroll position. - If the value provided is outside the current range of the - scroller, the value will be clipped. + If the value provided is negative, or greater than the maximum + horizontal position, the value will be clipped to the valid range. - If horizontal scrolling is disabled, returns ``None`` as the - current position, and raises :any:`ValueError` if an attempt - is made to change the position. + If horizontal scrolling is disabled, returns ``None`` as the current + position, and raises :any:`ValueError` if an attempt is made to change + the position. """ if not self.horizontal: return None @@ -168,7 +162,7 @@ def horizontal_position(self, horizontal_position): @property def max_vertical_position(self) -> int | None: - """The maximum vertical scroller position. + """The maximum vertical scroll position. Returns ``None`` if vertical scrolling is disabled. """ @@ -178,14 +172,14 @@ def max_vertical_position(self) -> int | None: @property def vertical_position(self) -> int | None: - """The current vertical scroller position. + """The current vertical scroll position. - If the value provided is outside the current range of the - scroller, the value will be clipped. + If the value provided is negative, or greater than the maximum + vertical position, the value will be clipped to the valid range. - If vertical scrolling is disabled, returns ``None`` as the - current position, and raises :any:`ValueError` if an attempt - is made to change the position. + If vertical scrolling is disabled, returns ``None`` as the current + position, and raises :any:`ValueError` if an attempt is made to change + the position. """ if not self.vertical: return None diff --git a/core/tests/widgets/test_base.py b/core/tests/widgets/test_base.py index 28259230b6..91c34b00de 100644 --- a/core/tests/widgets/test_base.py +++ b/core/tests/widgets/test_base.py @@ -1,5 +1,3 @@ -from unittest.mock import Mock, call - import pytest import toga @@ -108,14 +106,19 @@ def test_add_child_without_app(widget): # The impl's add_child has been invoked assert_action_performed_with(widget, "add child", child=child._impl) + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + 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 + window = toga.Window() + window.app = app + window.content = widget + # Clear the event log + EventLog.reset() # Widget has an app and window assert widget.app == app @@ -145,8 +148,11 @@ def test_add_child(widget): # The impl's add_child has been invoked assert_action_performed_with(widget, "add child", child=child._impl) - # The window layout has been refreshed - window.content.refresh.assert_called_once_with() + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + + # The window's container gets a refresh notification + assert_action_performed_with(window, "container refreshed") # App's widget index has been updated assert len(app.widgets) == 2 @@ -158,9 +164,11 @@ 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 + window = toga.Window() + window.app = app + window.content = widget + # Clear the event log + EventLog.reset() # Widget has an app and window assert widget.app == app @@ -204,8 +212,12 @@ def test_add_multiple_children(widget): 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() + # The widget's layout has been refreshed + # There will be multiple refresh calls + assert_action_performed_with(widget, "refresh") + + # The window's container gets a refresh notification + assert_action_performed_with(window, "container refreshed") # App's widget index has been updated assert len(app.widgets) == 4 @@ -238,6 +250,12 @@ def test_reparent_child(widget): # The impl's add_child has been invoked assert_action_performed_with(widget, "add child", child=child._impl) + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + + # The layout of the old parent has been refreshed + assert_action_performed_with(other, "refresh") + def test_reparent_child_to_self(widget): "Reparenting a widget to the same parent is a no-op" @@ -262,6 +280,9 @@ def test_reparent_child_to_self(widget): # as the widget was already a child assert_action_not_performed(widget, "add child") + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + def test_insert_child_into_leaf(): "A child cannot be inserted into a leaf node" @@ -308,14 +329,19 @@ def test_insert_child_without_app(widget): # The impl's insert_child has been invoked assert_action_performed_with(widget, "insert child", child=child._impl) + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + 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 + window = toga.Window() + window.app = app + window.content = widget + # Clear the event log + EventLog.reset() # Widget has an app and window assert widget.app == app @@ -345,8 +371,11 @@ def test_insert_child(widget): # The impl's insert_child has been invoked assert_action_performed_with(widget, "insert child", child=child._impl) - # The window layout has been refreshed - window.content.refresh.assert_called_once_with() + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + + # The window's container gets a refresh notification + assert_action_performed_with(window, "container refreshed") # App's widget index has been updated assert len(app.widgets) == 2 @@ -358,9 +387,11 @@ 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 + window = toga.Window() + window.app = app + window.content = widget + # Clear the event log + EventLog.reset() # Widget has an app and window assert widget.app == app @@ -406,8 +437,11 @@ def test_insert_position(widget): 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 + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + + # The window's container gets a refresh notification + assert_action_performed_with(window, "container refreshed") # App's widget index has been updated assert len(app.widgets) == 4 @@ -421,9 +455,11 @@ 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 + window = toga.Window() + window.app = app + window.content = widget + # Clear the event log + EventLog.reset() # Widget has an app and window assert widget.app == app @@ -454,8 +490,11 @@ def test_insert_bad_position(widget): # The impl's insert_child has been invoked assert_action_performed_with(widget, "insert child", child=child._impl) - # The window layout has been refreshed - window.content.refresh.assert_called_once_with() + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + + # The window's container gets a refresh notification + assert_action_performed_with(window, "container refreshed") # App's widget index has been updated assert len(app.widgets) == 2 @@ -486,6 +525,12 @@ def test_insert_reparent_child(widget): # The impl's insert_child has been invoked assert_action_performed_with(widget, "insert child", child=child._impl) + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + + # The original parent's layout has been refreshed + assert_action_performed_with(other, "refresh") + def test_insert_reparent_child_to_self(widget): "Reparenting a widget to the same parent by insertion is a no-op" @@ -510,6 +555,9 @@ def test_insert_reparent_child_to_self(widget): # as the widget was already a child assert_action_not_performed(widget, "insert child") + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + def test_remove_child_without_app(widget): "A child without an app or window can be removed from a widget" @@ -536,6 +584,9 @@ def test_remove_child_without_app(widget): # The impl's remove_child has been invoked assert_action_performed_with(widget, "remove child", child=child._impl) + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + def test_remove_child(widget): "A child associated with an app & window can be removed from a widget" @@ -544,9 +595,11 @@ def test_remove_child(widget): widget.add(child) app = toga.App("Test", "com.example.test") - window = Mock() - widget.app = app - widget.window = window + window = toga.Window() + window.app = app + window.content = widget + # Clear the event log + EventLog.reset() assert widget.children == [child] assert child.parent == widget @@ -567,8 +620,11 @@ def test_remove_child(widget): # The impl's remove_child has been invoked assert_action_performed_with(widget, "remove child", child=child._impl) - # The window layout has been refreshed - window.content.refresh.assert_called_once_with() + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + + # The window's container gets a refresh notification + assert_action_performed_with(window, "container refreshed") def test_remove_multiple_children(widget): @@ -580,9 +636,11 @@ def test_remove_multiple_children(widget): widget.add(child1, child2, child3) app = toga.App("Test", "com.example.test") - window = Mock() - widget.app = app - widget.window = window + window = toga.Window() + window.app = app + window.content = widget + # Clear the event log + EventLog.reset() assert widget.children == [child1, child2, child3] for child in widget.children: @@ -613,8 +671,11 @@ def test_remove_multiple_children(widget): 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() + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + + # The window's container gets a refresh notification + assert_action_performed_with(window, "container refreshed") def test_clear_all_children(widget): @@ -626,9 +687,11 @@ def test_clear_all_children(widget): widget.add(child1, child2, child3) app = toga.App("Test", "com.example.test") - window = Mock() - widget.app = app - widget.window = window + window = toga.Window() + window.app = app + window.content = widget + # Clear the event log + EventLog.reset() assert widget.children == [child1, child2, child3] for child in widget.children: @@ -660,16 +723,21 @@ def test_clear_all_children(widget): assert_action_performed_with(widget, "remove child", child=child2._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() + # The widget's layout has been refreshed + assert_action_performed_with(widget, "refresh") + + # The window's container gets a refresh notification + assert_action_performed_with(window, "container refreshed") def test_clear_no_children(widget): "No changes are made (no-op) if widget has no children" app = toga.App("Test", "com.example.test") - window = Mock() - widget.app = app - widget.window = window + window = toga.Window() + window.app = app + window.content = widget + # Clear the event log + EventLog.reset() assert widget.children == [] @@ -679,17 +747,22 @@ def test_clear_no_children(widget): # Parent doesn't have any children still assert widget.children == [] - # The window layout has not been refreshed - window.content.refresh.assert_not_called() + # The widget's layout has *not* been refreshed + assert_action_not_performed(widget, "refresh") + + # The window's container gets a refresh notification + assert_action_not_performed(window, "container refreshed") def test_clear_leaf_node(): "No changes are made to leaf node that cannot have children" leaf = TestLeafWidget() app = toga.App("Test", "com.example.test") - window = Mock() - leaf.app = app - leaf.window = window + window = toga.Window() + window.app = app + window.content = leaf + # Clear the event log + EventLog.reset() assert leaf.children == [] @@ -699,8 +772,11 @@ def test_clear_leaf_node(): # Parent doesn't have any children still assert leaf.children == [] - # The window layout has not been refreshed - window.content.refresh.assert_not_called() + # The widget's layout has *not* been refreshed + assert_action_not_performed(leaf, "refresh") + + # The window's container gets a refresh notification + assert_action_not_performed(window, "container refreshed") def test_remove_from_non_parent(widget): @@ -725,6 +801,9 @@ def test_remove_from_non_parent(widget): # The impl's remove_child has been invoked assert_action_not_performed(widget, "remove child") + # The widget's layout has *not* been refreshed + assert_action_not_performed(widget, "refresh") + def test_set_app(widget): "A widget can be assigned to an app" diff --git a/core/tests/widgets/test_scrollcontainer.py b/core/tests/widgets/test_scrollcontainer.py index bd785d349b..8b392fca90 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -65,14 +65,15 @@ def test_widget_created_with_values(content, on_scroll_handler): assert scroll_container.on_scroll._raw == on_scroll_handler # The content has been assigned to the widget - assert_action_performed_with(scroll_container, "set content", widget=content) + assert_action_performed_with( + scroll_container, + "set content", + widget=content._impl, + ) # The scroll container has been refreshed assert_action_performed(scroll_container, "refresh") - # The content and the frame has been refreshed - assert_action_performed(content, "refresh") - # The scroll handler hasn't been invoked on_scroll_handler.assert_not_called() @@ -170,14 +171,15 @@ def test_set_content(app, window, scroll_container, content): scroll_container.content = new_content # The content has been assigned to the widget - assert_action_performed_with(scroll_container, "set content", widget=new_content) + assert_action_performed_with( + scroll_container, + "set content", + widget=new_content._impl, + ) # The scroll container has been refreshed assert_action_performed(scroll_container, "refresh") - # The new content has been refreshed - assert_action_performed(new_content, "refresh") - # The content has been assigned assert scroll_container.content == new_content diff --git a/docs/reference/api/containers/scrollcontainer.rst b/docs/reference/api/containers/scrollcontainer.rst index dfd86ddaec..c7a2407508 100644 --- a/docs/reference/api/containers/scrollcontainer.rst +++ b/docs/reference/api/containers/scrollcontainer.rst @@ -1,8 +1,8 @@ Scroll Container ================ -An container widget that can display a layout larger that the area of the -container, with overflow controlled by scrollbars. +A container widget that can display a layout larger that the area of the +container, with overflow controlled by scroll bars. .. figure:: /reference/images/ScrollContainer.png :align: center diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 61e40e81d6..fa0a96002b 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -58,8 +58,8 @@ Layout widgets Usage Description ==================================================================== ======================================================================== :doc:`Box ` A generic container for other widgets. Used to construct layouts. - :doc:`ScrollContainer ` An container widget that can display a layout larger that the area of - the container, with overflow controlled by scrollbars. + :doc:`ScrollContainer ` A container widget that can display a layout larger that the area of + the container, with overflow controlled by scroll bars. :doc:`SplitContainer ` Split Container :doc:`OptionContainer ` Option Container ==================================================================== ======================================================================== diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 8b1f704994..fc56f0114f 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -24,7 +24,7 @@ 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,|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`,An container widget that can display a layout larger that the area of the container, with overflow controlled by scrollbars.,|b|,|b|,|b|,|b|,|b|, +ScrollContainer,Layout Widget,:class:`~toga.widgets.scrollcontainer.ScrollContainer`,A container widget that can display a layout larger that the area of the container, with overflow controlled by scroll bars.,|b|,|b|,|b|,|b|,|b|, SplitContainer,Layout Widget,:class:`~toga.widgets.splitcontainer.SplitContainer`,Split Container,|b|,|b|,|b|,,, OptionContainer,Layout Widget,:class:`~toga.widgets.optioncontainer.OptionContainer`,Option Container,|b|,|b|,|b|,,, App Paths,Resource,:class:~`toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|, diff --git a/dummy/src/toga_dummy/widgets/base.py b/dummy/src/toga_dummy/widgets/base.py index 3bfd3451b8..b578c7e6dd 100644 --- a/dummy/src/toga_dummy/widgets/base.py +++ b/dummy/src/toga_dummy/widgets/base.py @@ -7,9 +7,13 @@ def __init__(self, interface): super().__init__() self.interface = interface self.interface._impl = self - self.viewport = None + self.container = None self.create() + @property + def viewport(self): + return self.container + def create(self): self._action("create Widget") @@ -61,12 +65,15 @@ def set_background_color(self, color): ###################################################################### def add_child(self, child): + child.container = self.container self._action("add child", child=child) def insert_child(self, index, child): + child.container = self.container self._action("insert child", index=index, child=child) def remove_child(self, child): + child.container = None self._action("remove child", child=child) def refresh(self): diff --git a/dummy/src/toga_dummy/widgets/scrollcontainer.py b/dummy/src/toga_dummy/widgets/scrollcontainer.py index c52cd5e506..6d90e561a2 100644 --- a/dummy/src/toga_dummy/widgets/scrollcontainer.py +++ b/dummy/src/toga_dummy/widgets/scrollcontainer.py @@ -1,4 +1,5 @@ from ..utils import not_required +from ..window import Container from .base import Widget @@ -6,8 +7,15 @@ class ScrollContainer(Widget): def create(self): self._action("create ScrollContainer") - self._horizontal_position = 0 - self._vertical_position = 0 + self.scroll_container = Container(self) + + # Required to satisfy the scroll container + def get_width(self): + return 3700 + + # Required to satisfy the scroll container + def get_height(self): + return 4200 def set_content(self, widget): self._action("set content", widget=widget) diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index 59fb8c5beb..94ec1fbbbb 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -2,25 +2,30 @@ @not_required -class Viewport: - def __init__(self, window): +class Container: + def __init__(self, native): self.baseline_dpi = 96 self.dpi = 96 - self.window = window + + self.native = native @property def width(self): - return self.window.get_size()[0] + return self.native.get_size()[0] @property def height(self): - return self.window.get_size()[1] + return self.native.get_size()[1] + + def refreshed(self): + self.native._action("container refreshed") class Window(LoggedObject): def __init__(self, interface, title, position, size): super().__init__() self.interface = interface + self.container = Container(self) self.set_title(title) self.set_position(position) @@ -30,12 +35,17 @@ def create_toolbar(self): self._action("create toolbar") def clear_content(self): + try: + widget = self._get_value("content") + widget.container = self.container + except AttributeError: + pass self._action("clear content") def set_content(self, widget): + widget.container = self.container self._action("set content", widget=widget) self._set_value("content", widget) - widget.viewport = Viewport(self) def get_title(self): return self._get_value("title") @@ -50,7 +60,7 @@ def set_position(self, position): self._set_value("position", position) def get_size(self): - return self._get_value("size") + return self._get_value("size", (640, 480)) def set_size(self, size): self._set_value("size", size) diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index a6c6e6a309..d753b6f62d 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import pytest import toga @@ -54,8 +56,15 @@ async def small_content(): @pytest.fixture -async def widget(content): - return toga.ScrollContainer(content=content, style=Pack(flex=1)) +async def on_scroll(): + return Mock() + + +@pytest.fixture +async def widget(content, on_scroll): + return toga.ScrollContainer( + content=content, style=Pack(flex=1), on_scroll=on_scroll + ) async def test_clear_content(widget, probe, small_content): @@ -78,7 +87,7 @@ async def test_clear_content(widget, probe, small_content): assert probe.document_height == probe.height -async def test_enable_horizontal_scrolling(widget, probe, content): +async def test_enable_horizontal_scrolling(widget, probe, content, on_scroll): "Horizontal scrolling can be disabled" content.style.direction = ROW @@ -94,11 +103,13 @@ async def test_enable_horizontal_scrolling(widget, probe, content): await probe.redraw("Horizontal scrolling is enabled") widget.horizontal_position = 120 + await probe.wait_for_scroll_completion() await probe.redraw("Horizontal scroll was allowed") assert widget.horizontal_position == 120 + on_scroll.assert_called_once_with(widget) -async def test_enable_vertical_scrolling(widget, probe): +async def test_enable_vertical_scrolling(widget, probe, on_scroll): widget.vertical = False await probe.redraw("Vertical scrolling is disabled") @@ -111,11 +122,13 @@ async def test_enable_vertical_scrolling(widget, probe): await probe.redraw("Vertical scrolling is enabled") widget.vertical_position = 120 + await probe.wait_for_scroll_completion() await probe.redraw("Vertical scroll was allowed") assert widget.vertical_position == 120 + on_scroll.assert_called_once_with(widget) -async def test_vertical_scroll(widget, probe): +async def test_vertical_scroll(widget, probe, on_scroll): "The widget can be scrolled vertically." assert probe.document_width == probe.width assert probe.document_height == 6000 @@ -127,25 +140,38 @@ async def test_vertical_scroll(widget, probe): assert widget.vertical_position == 0 widget.vertical_position = probe.height * 3 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll down 3 pages") assert widget.vertical_position == probe.height * 3 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() widget.vertical_position = 0 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll back to origin") assert widget.vertical_position == 0 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() widget.vertical_position = 10000 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll past the end") assert widget.vertical_position == widget.max_vertical_position + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() widget.vertical_position = -100 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll past the start") assert widget.vertical_position == 0 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() -async def test_vertical_scroll_small_content(widget, probe, small_content): +async def test_vertical_scroll_small_content(widget, probe, small_content, on_scroll): "The widget can be scrolled vertically when the content doesn't need scrolling." widget.content = small_content + await probe.redraw("Content has been switched for a small document") assert probe.document_width == probe.width assert probe.document_height == probe.height @@ -157,17 +183,24 @@ async def test_vertical_scroll_small_content(widget, probe, small_content): assert widget.vertical_position == 0 widget.vertical_position = probe.height * 3 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll down 3 pages") assert widget.vertical_position == 0 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() widget.vertical_position = 0 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll back to origin") assert widget.vertical_position == 0 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() -async def test_horizontal_scroll(widget, probe, content): +async def test_horizontal_scroll(widget, probe, content, on_scroll): "The widget can be scrolled horizontally." content.style.direction = ROW + await probe.redraw("Content has been switched for a wide document") assert probe.document_width == 20000 assert probe.document_height == probe.height @@ -179,26 +212,39 @@ async def test_horizontal_scroll(widget, probe, content): assert widget.vertical_position == 0 widget.horizontal_position = probe.height * 3 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll down 3 pages") assert widget.horizontal_position == probe.height * 3 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() widget.horizontal_position = 0 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll back to origin") assert widget.horizontal_position == 0 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() widget.horizontal_position = 30000 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll past the end") assert widget.horizontal_position == widget.max_horizontal_position + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() widget.horizontal_position = -100 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll past the start") assert widget.horizontal_position == 0 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() -async def test_horizontal_scroll_small_content(widget, probe, small_content): +async def test_horizontal_scroll_small_content(widget, probe, small_content, on_scroll): "The widget can be scrolled horizontally when the content doesn't need scrolling." small_content.style.direction = ROW widget.content = small_content + await probe.redraw("Content has been switched for a small wide document") assert probe.document_width == probe.width assert probe.document_height == probe.height @@ -210,9 +256,76 @@ async def test_horizontal_scroll_small_content(widget, probe, small_content): assert widget.vertical_position == 0 widget.horizontal_position = probe.height * 3 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll down 3 pages") assert widget.horizontal_position == 0 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() widget.horizontal_position = 0 + await probe.wait_for_scroll_completion() await probe.redraw("Scroll back to origin") assert widget.horizontal_position == 0 + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() + + +async def test_scroll_both(widget, probe, content, on_scroll): + "The widget can be scrolled in both axes." + # Add some wide content + content.add( + toga.Label( + "This is a long label", + style=Pack( + width=2000, + background_color=CORNFLOWERBLUE, + padding=20, + height=20, + ), + ) + ) + await probe.redraw("Content has been modified to be wide as well as tall") + assert probe.document_width == 2040 + assert probe.document_height == 6060 + + assert widget.horizontal_position == 0 + assert widget.vertical_position == 0 + + widget.horizontal_position = 1000 + widget.vertical_position = 2000 + await probe.wait_for_scroll_completion() + await probe.redraw("Scroll to mid document") + assert widget.horizontal_position == 1000 + assert widget.vertical_position == 2000 + on_scroll.assert_called_with(widget) + on_scroll.reset_mock() + + widget.horizontal_position = 0 + widget.vertical_position = 20000 + await probe.wait_for_scroll_completion() + await probe.redraw("Scroll to bottom left") + assert widget.horizontal_position == 0 + assert widget.vertical_position == 6060 - probe.height + on_scroll.assert_called_with(widget) + on_scroll.reset_mock() + + widget.horizontal_position = 10000 + await probe.wait_for_scroll_completion() + await probe.redraw("Scroll to bottom right") + assert widget.horizontal_position == 2040 - probe.width + assert widget.vertical_position == 6060 - probe.height + on_scroll.assert_called_once_with(widget) + on_scroll.reset_mock() + + +async def test_manual_scroll(widget, probe, content, on_scroll): + "The widget can be scrolled manually." + await probe.scroll() + await probe.wait_for_scroll_completion() + await probe.redraw("Widget has been manually scrolled manually") + assert widget.horizontal_position == 0 + + # We don't care where it's been scrolled to; and there may have been + # multiple scroll events. + assert widget.vertical_position > 0 + on_scroll.assert_called_with(widget) From 73edde4e85c31f721fe57ab8d667129b628f8be5 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 12 Jun 2023 14:15:07 +0800 Subject: [PATCH 08/40] Correct CI failures by using frame, rather than contentView for the scroll offset. --- .../src/toga_cocoa/widgets/scrollcontainer.py | 30 ++++++---------- core/src/toga/widgets/scrollcontainer.py | 22 ++++++++++-- core/tests/widgets/test_scrollcontainer.py | 36 ++++++++++++++----- gtk/src/toga_gtk/container.py | 3 ++ winforms/src/toga_winforms/window.py | 3 ++ 5 files changed, 64 insertions(+), 30 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index c7571c75aa..57674a1cd1 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -122,21 +122,16 @@ def rehint(self): def get_max_vertical_position(self): return max( 0, - self.native.documentView.bounds.size.height - - self.native.contentView.bounds.size.height, + int( + self.native.documentView.bounds.size.height + - self.native.frame.size.height + ), ) def get_vertical_position(self): - return self.native.contentView.bounds.origin.y + return int(self.native.contentView.bounds.origin.y) def set_vertical_position(self, vertical_position): - if vertical_position < 0: - vertical_position = 0 - else: - max_value = self.get_max_vertical_position() - if vertical_position > max_value: - vertical_position = max_value - new_position = NSMakePoint( self.native.contentView.bounds.origin.x, vertical_position, @@ -148,21 +143,16 @@ def set_vertical_position(self, vertical_position): def get_max_horizontal_position(self): return max( 0, - self.native.documentView.bounds.size.width - - self.native.contentView.bounds.size.width, + int( + self.native.documentView.bounds.size.width + - self.native.frame.size.width + ), ) def get_horizontal_position(self): - return self.native.contentView.bounds.origin.x + return int(self.native.contentView.bounds.origin.x) def set_horizontal_position(self, horizontal_position): - if horizontal_position < 0: - horizontal_position = 0 - else: - max_value = self.get_max_horizontal_position() - if horizontal_position > max_value: - horizontal_position = max_value - new_position = NSMakePoint( horizontal_position, self.native.contentView.bounds.origin.y, diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index 7c4f89d2a8..d938264aa3 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -158,7 +158,16 @@ def horizontal_position(self, horizontal_position): raise ValueError( "Cannot set horizontal position when horizontal is not set." ) - self._impl.set_horizontal_position(int(horizontal_position)) + + horizontal_position = int(horizontal_position) + if horizontal_position < 0: + horizontal_position = 0 + else: + max_value = self.max_horizontal_position + if horizontal_position > max_value: + horizontal_position = max_value + + self._impl.set_horizontal_position(horizontal_position) @property def max_vertical_position(self) -> int | None: @@ -189,4 +198,13 @@ def vertical_position(self) -> int | None: def vertical_position(self, vertical_position): if not self.vertical: raise ValueError("Cannot set vertical position when vertical is not set.") - self._impl.set_vertical_position(int(vertical_position)) + + vertical_position = int(vertical_position) + if vertical_position < 0: + vertical_position = 0 + else: + max_value = self.max_vertical_position + if vertical_position > max_value: + vertical_position = max_value + + self._impl.set_vertical_position(vertical_position) diff --git a/core/tests/widgets/test_scrollcontainer.py b/core/tests/widgets/test_scrollcontainer.py index 8b392fca90..d9b1b3699c 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -266,11 +266,21 @@ def test_vertical(scroll_container, content, value, expected): assert_action_performed(content, "refresh") -def test_horizontal_position(scroll_container): - "The horizontal position can be set and retrieved" - scroll_container.horizontal_position = 10 +@pytest.mark.parametrize( + "position, expected", + [ + (10, 10), + (-100, 0), # Clipped to minimum value + (1500, 1000), # Clipped to maximum value + ("10", 10), # String, converted to int + (10.1, 10), # Float, converted to int + ], +) +def test_horizontal_position(scroll_container, position, expected): + "The horizontal position can be set (clipped if necessary) and retrieved" + scroll_container.horizontal_position = position - assert scroll_container.horizontal_position == 10 + assert scroll_container.horizontal_position == expected assert scroll_container.max_horizontal_position == 1000 @@ -292,11 +302,21 @@ def test_horizontal_position_when_not_horizontal(scroll_container): scroll_container.horizontal_position = 0.5 -def test_vertical_position(scroll_container): - "The vertical position can be set and retrieved" - scroll_container.vertical_position = 10 +@pytest.mark.parametrize( + "position, expected", + [ + (10, 10), + (-100, 0), # Clipped to minimum value + (2500, 2000), # Clipped to maximum value + ("10", 10), # String, converted to int + (10.1, 10), # Float, converted to int + ], +) +def test_vertical_position(scroll_container, position, expected): + "The vertical position can be set (clipped if necessary) and retrieved" + scroll_container.vertical_position = position - assert scroll_container.vertical_position == 10 + assert scroll_container.vertical_position == expected assert scroll_container.max_vertical_position == 2000 diff --git a/gtk/src/toga_gtk/container.py b/gtk/src/toga_gtk/container.py index d4ab4130a5..ab0184f014 100644 --- a/gtk/src/toga_gtk/container.py +++ b/gtk/src/toga_gtk/container.py @@ -26,6 +26,9 @@ def __init__(self): # A flag that can be used to explicitly flag that a redraw is required. self.needs_redraw = True + def refreshed(self): + pass + def make_dirty(self, widget=None): """Mark the container (or a specific widget in the container) as dirty. diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index c2f02ffcb5..9cbdf5e183 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -31,6 +31,9 @@ def dpi(self): return self.baseline_dpi return self.native.CreateGraphics().DpiX + def refreshed(self): + pass + class Window: def __init__(self, interface, title, position, size): From 73e1d304ed7f381c5b2ab965d4859399b62bcecb Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 13 Jun 2023 08:32:10 +0800 Subject: [PATCH 09/40] GTK ScrollContainer to 100%. --- core/src/toga/widgets/scrollcontainer.py | 1 + gtk/src/toga_gtk/container.py | 3 + gtk/src/toga_gtk/widgets/scrollcontainer.py | 72 +++++++++++-------- gtk/tests_backend/widgets/scrollcontainer.py | 31 ++++++++ testbed/tests/widgets/test_scrollcontainer.py | 51 +++++++++---- 5 files changed, 115 insertions(+), 43 deletions(-) create mode 100644 gtk/tests_backend/widgets/scrollcontainer.py diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index d938264aa3..8acf5894e9 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -30,6 +30,7 @@ def __init__( super().__init__(id=id, style=style) self._content = None + self.on_scroll = None # Create a platform specific implementation of a Scroll Container self._impl = self.factory.ScrollContainer(interface=self) diff --git a/gtk/src/toga_gtk/container.py b/gtk/src/toga_gtk/container.py index ab0184f014..b7f162a923 100644 --- a/gtk/src/toga_gtk/container.py +++ b/gtk/src/toga_gtk/container.py @@ -79,6 +79,9 @@ def content(self, widget): self._content = widget if widget: widget.container = self + self.make_dirty(widget) + else: + self.make_dirty() def recompute(self): """Rehint and re-layout the container's content, if necessary. diff --git a/gtk/src/toga_gtk/widgets/scrollcontainer.py b/gtk/src/toga_gtk/widgets/scrollcontainer.py index 6e468e60ad..efd3d1ab27 100644 --- a/gtk/src/toga_gtk/widgets/scrollcontainer.py +++ b/gtk/src/toga_gtk/widgets/scrollcontainer.py @@ -1,3 +1,5 @@ +from travertino.size import at_least + from ..container import TogaContainer from ..libs import Gtk from .base import Widget @@ -7,6 +9,9 @@ class ScrollContainer(Widget): def create(self): self.native = Gtk.ScrolledWindow() + self.native.get_hadjustment().connect("changed", self.gtk_on_changed) + self.native.get_vadjustment().connect("changed", self.gtk_on_changed) + # 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) @@ -14,64 +19,75 @@ def create(self): self.native.set_overlay_scrolling(True) - self.inner_container = TogaContainer() - self.native.add(self.inner_container) + self.document_container = TogaContainer() + self.native.add(self.document_container) + + def gtk_on_changed(self, *args): + self.interface.on_scroll(None) def set_content(self, widget): - self.inner_container.content = widget + self.document_container.content = widget # Force the display of the new content self.native.show_all() def set_app(self, app): - if self.interface.content: - self.interface.content.app = app + self.interface.content.app = app def set_window(self, window): - if self.interface.content: - self.interface.content.window = window + self.interface.content.window = window + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + + def get_horizontal(self): + return self.native.get_policy()[0] == Gtk.PolicyType.AUTOMATIC def set_horizontal(self, value): self.native.set_policy( - Gtk.PolicyType.AUTOMATIC - if self.interface.horizontal - else Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC if value else Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC if self.interface.vertical else Gtk.PolicyType.NEVER, ) + def get_vertical(self): + return self.native.get_policy()[1] == Gtk.PolicyType.AUTOMATIC + def set_vertical(self, value): self.native.set_policy( Gtk.PolicyType.AUTOMATIC if self.interface.horizontal else Gtk.PolicyType.NEVER, - Gtk.PolicyType.AUTOMATIC - if self.interface.vertical - else Gtk.PolicyType.NEVER, + Gtk.PolicyType.AUTOMATIC if value else Gtk.PolicyType.NEVER, ) - def set_on_scroll(self, on_scroll): - self.interface.factory.not_implemented("ScrollContainer.set_on_scroll()") + def get_max_vertical_position(self): + return max( + 0, + self.native.get_vadjustment().get_upper() + - self.native.get_allocation().height, + ) def get_vertical_position(self): - self.interface.factory.not_implemented( - "ScrollContainer.get_vertical_position()" - ) - return 0 + return self.native.get_vadjustment().get_value() def set_vertical_position(self, vertical_position): - self.interface.factory.not_implemented( - "ScrollContainer.set_vertical_position()" + self.native.get_vadjustment().set_value(vertical_position) + self.interface.on_scroll(None) + + def get_max_horizontal_position(self): + return max( + 0, + self.native.get_hadjustment().get_upper() + - self.native.get_allocation().width, ) def get_horizontal_position(self): - self.interface.factory.not_implemented( - "ScrollContainer.get_horizontal_position()" - ) - return 0 + return self.native.get_hadjustment().get_value() def set_horizontal_position(self, horizontal_position): - self.interface.factory.not_implemented( - "ScrollContainer.set_horizontal_position()" - ) + print(self.native.get_policy()) + self.native.get_hadjustment().set_value(horizontal_position) + self.interface.on_scroll(None) diff --git a/gtk/tests_backend/widgets/scrollcontainer.py b/gtk/tests_backend/widgets/scrollcontainer.py new file mode 100644 index 0000000000..f387264ffe --- /dev/null +++ b/gtk/tests_backend/widgets/scrollcontainer.py @@ -0,0 +1,31 @@ +from toga_gtk.libs import Gtk + +from .base import SimpleProbe + + +class ScrollContainerProbe(SimpleProbe): + native_class = Gtk.ScrolledWindow + + @property + def has_content(self): + return self.impl.document_container.content is not None + + @property + def document_height(self): + return self.native.get_vadjustment().get_upper() + + @property + def document_width(self): + return self.native.get_hadjustment().get_upper() + + async def scroll(self): + # Fake a vertical scroll + self.native.get_vadjustment().set_value(200) + self.native.get_vadjustment().emit("changed") + + def repaint_needed(self): + return self.impl.document_container.needs_redraw or super().repaint_needed() + + async def wait_for_scroll_completion(self): + # Scroll isn't animated, so this is a no-op. + pass diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index d753b6f62d..43160b19a7 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -102,11 +102,14 @@ async def test_enable_horizontal_scrolling(widget, probe, content, on_scroll): widget.horizontal = True await probe.redraw("Horizontal scrolling is enabled") + # clear any scroll events caused by setup + on_scroll.reset_mock() + widget.horizontal_position = 120 await probe.wait_for_scroll_completion() await probe.redraw("Horizontal scroll was allowed") assert widget.horizontal_position == 120 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) async def test_enable_vertical_scrolling(widget, probe, on_scroll): @@ -121,11 +124,14 @@ async def test_enable_vertical_scrolling(widget, probe, on_scroll): widget.vertical = True await probe.redraw("Vertical scrolling is enabled") + # clear any scroll events caused by setup + on_scroll.reset_mock() + widget.vertical_position = 120 await probe.wait_for_scroll_completion() await probe.redraw("Vertical scroll was allowed") assert widget.vertical_position == 120 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) async def test_vertical_scroll(widget, probe, on_scroll): @@ -139,32 +145,35 @@ async def test_vertical_scroll(widget, probe, on_scroll): assert widget.horizontal_position == 0 assert widget.vertical_position == 0 + # clear any scroll events caused by setup + on_scroll.reset_mock() + widget.vertical_position = probe.height * 3 await probe.wait_for_scroll_completion() await probe.redraw("Scroll down 3 pages") assert widget.vertical_position == probe.height * 3 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() widget.vertical_position = 0 await probe.wait_for_scroll_completion() await probe.redraw("Scroll back to origin") assert widget.vertical_position == 0 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() widget.vertical_position = 10000 await probe.wait_for_scroll_completion() await probe.redraw("Scroll past the end") assert widget.vertical_position == widget.max_vertical_position - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() widget.vertical_position = -100 await probe.wait_for_scroll_completion() await probe.redraw("Scroll past the start") assert widget.vertical_position == 0 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() @@ -182,18 +191,21 @@ async def test_vertical_scroll_small_content(widget, probe, small_content, on_sc assert widget.horizontal_position == 0 assert widget.vertical_position == 0 + # clear any scroll events caused by setup + on_scroll.reset_mock() + widget.vertical_position = probe.height * 3 await probe.wait_for_scroll_completion() await probe.redraw("Scroll down 3 pages") assert widget.vertical_position == 0 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() widget.vertical_position = 0 await probe.wait_for_scroll_completion() await probe.redraw("Scroll back to origin") assert widget.vertical_position == 0 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() @@ -211,32 +223,35 @@ async def test_horizontal_scroll(widget, probe, content, on_scroll): assert widget.horizontal_position == 0 assert widget.vertical_position == 0 + # clear any scroll events caused by setup + on_scroll.reset_mock() + widget.horizontal_position = probe.height * 3 await probe.wait_for_scroll_completion() await probe.redraw("Scroll down 3 pages") assert widget.horizontal_position == probe.height * 3 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() widget.horizontal_position = 0 await probe.wait_for_scroll_completion() await probe.redraw("Scroll back to origin") assert widget.horizontal_position == 0 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() widget.horizontal_position = 30000 await probe.wait_for_scroll_completion() await probe.redraw("Scroll past the end") assert widget.horizontal_position == widget.max_horizontal_position - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() widget.horizontal_position = -100 await probe.wait_for_scroll_completion() await probe.redraw("Scroll past the start") assert widget.horizontal_position == 0 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() @@ -255,18 +270,21 @@ async def test_horizontal_scroll_small_content(widget, probe, small_content, on_ assert widget.horizontal_position == 0 assert widget.vertical_position == 0 + # clear any scroll events caused by setup + on_scroll.reset_mock() + widget.horizontal_position = probe.height * 3 await probe.wait_for_scroll_completion() await probe.redraw("Scroll down 3 pages") assert widget.horizontal_position == 0 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() widget.horizontal_position = 0 await probe.wait_for_scroll_completion() await probe.redraw("Scroll back to origin") assert widget.horizontal_position == 0 - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() @@ -291,6 +309,9 @@ async def test_scroll_both(widget, probe, content, on_scroll): assert widget.horizontal_position == 0 assert widget.vertical_position == 0 + # clear any scroll events caused by setup + on_scroll.reset_mock() + widget.horizontal_position = 1000 widget.vertical_position = 2000 await probe.wait_for_scroll_completion() @@ -314,7 +335,7 @@ async def test_scroll_both(widget, probe, content, on_scroll): await probe.redraw("Scroll to bottom right") assert widget.horizontal_position == 2040 - probe.width assert widget.vertical_position == 6060 - probe.height - on_scroll.assert_called_once_with(widget) + on_scroll.assert_called_with(widget) on_scroll.reset_mock() From bea23c1440fa959e471e708f291f5a03ff5c3f02 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 13 Jun 2023 08:49:18 +0800 Subject: [PATCH 10/40] Add a content handler to the cocoa container impl, matching GTK. --- .../{containers.py => container.py} | 60 +++++++++++++++---- cocoa/src/toga_cocoa/widgets/base.py | 1 - cocoa/src/toga_cocoa/widgets/box.py | 2 +- .../src/toga_cocoa/widgets/optioncontainer.py | 5 +- .../src/toga_cocoa/widgets/scrollcontainer.py | 11 +--- .../src/toga_cocoa/widgets/splitcontainer.py | 5 +- cocoa/src/toga_cocoa/window.py | 9 ++- 7 files changed, 60 insertions(+), 33 deletions(-) rename cocoa/src/toga_cocoa/{containers.py => container.py} (65%) diff --git a/cocoa/src/toga_cocoa/containers.py b/cocoa/src/toga_cocoa/container.py similarity index 65% rename from cocoa/src/toga_cocoa/containers.py rename to cocoa/src/toga_cocoa/container.py index 6cf5695e98..43742e6560 100644 --- a/cocoa/src/toga_cocoa/containers.py +++ b/cocoa/src/toga_cocoa/container.py @@ -19,22 +19,53 @@ def isFlipped(self) -> bool: class BaseContainer: - def __init__(self, on_refresh=None): + def __init__(self, content=None, on_refresh=None): + """A base class for macOS containers. + + :param content: The widget impl that is the container's initial content. + :param on_refresh: The callback to be notified when this container's layout is + refreshed. + """ + self._content = content self.on_refresh = on_refresh # macOS always renders at 96dpi. Scaling is handled # transparently at the level of the screen compositor. self.dpi = 96 self.baseline_dpi = self.dpi + @property + def content(self): + """The Toga implementation widget that is the root content of this container. + + All children of the root content will also be added to the container as a result + of assigning content. + + If the container already has content, the old content will be replaced. The old + root content and all it's children will be removed from the container. + """ + return self._content + + @content.setter + def content(self, widget): + if self._content: + self._content.container = None + + self._content = widget + if widget: + widget.container = self + def refreshed(self): if self.on_refresh: self.on_refresh() class MinimumContainer(BaseContainer): - def __init__(self): - """A container for evaluating the minumum possible size for a layout""" - super().__init__() + def __init__(self, content=None): + """A container for evaluating the minumum possible size for a layout + + :param content: The widget impl that is the container's initial content. + """ + super().__init__(content=content) self.width = 0 self.height = 0 @@ -42,21 +73,26 @@ def __init__(self): class Container(BaseContainer): def __init__( self, + content=None, min_width=100, min_height=100, layout_native=None, on_refresh=None, ): - """ + """A container for real layouts. + + Creates and enforces minimum size constraints on the container widget. + + :param content: The widget impl that is the container's initial content. :param min_width: The minimum width to enforce on the container :param min_height: The minimum height to enforce on the container - :param layout_native: The native widget that should be used to provide - size hints to the layout. This will usually be the container widget - itself; however, for widgets like ScrollContainer where the layout - needs to be computed based on a different size to what will be - rendered, the source of the size can be different. - :param on_refresh: The callback to be notified when this container's - layout is refreshed. + :param layout_native: The native widget that should be used to provide size + hints to the layout. By default, this will usually be the container widget + itself; however, for widgets like ScrollContainer where the layout needs to + be computed based on a different size to what will be rendered, the source + of the size can be different. + :param on_refresh: The callback to be notified when this container's layout is + refreshed. """ super().__init__(on_refresh=on_refresh) self.native = TogaView.alloc().init() diff --git a/cocoa/src/toga_cocoa/widgets/base.py b/cocoa/src/toga_cocoa/widgets/base.py index 2036860438..39cd45b17d 100644 --- a/cocoa/src/toga_cocoa/widgets/base.py +++ b/cocoa/src/toga_cocoa/widgets/base.py @@ -11,7 +11,6 @@ def __init__(self, interface): self.interface = interface self.interface._impl = self self._container = None - self._viewport = None self.constraints = None self.native = None self.create() diff --git a/cocoa/src/toga_cocoa/widgets/box.py b/cocoa/src/toga_cocoa/widgets/box.py index 5ab2174cf2..de3273ecd8 100644 --- a/cocoa/src/toga_cocoa/widgets/box.py +++ b/cocoa/src/toga_cocoa/widgets/box.py @@ -1,6 +1,6 @@ from travertino.size import at_least -from toga_cocoa.containers import TogaView +from toga_cocoa.container import TogaView from .base import Widget diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index 6bc9314f13..6b21b3eee6 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -1,7 +1,7 @@ from rubicon.objc import objc_method from travertino.size import at_least -from toga_cocoa.containers import Container +from toga_cocoa.container import Container from toga_cocoa.libs import NSObject, NSTabView, NSTabViewItem from ..libs import objc_property @@ -53,8 +53,7 @@ def add_content(self, index, text, widget): text (str): The text for the option container widget: The widget or widget tree that belongs to the text. """ - container = Container() - widget.container = container + container = Container(content=widget) item = NSTabViewItem.alloc().init() item.label = text diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 57674a1cd1..aeef3214cc 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -1,7 +1,7 @@ from rubicon.objc import SEL, objc_method, objc_property from travertino.size import at_least -from toga_cocoa.containers import Container +from toga_cocoa.container import Container from toga_cocoa.libs import ( NSColor, NSMakePoint, @@ -62,13 +62,8 @@ def create(self): self.add_constraints() def set_content(self, widget): - # If there's existing content, clear its container - if self.interface.content: - self.interface.content._impl.container = None - - # If there's new content, set the container of the content - if widget: - widget.container = self.document_container + # Set the document container's content to the new widget + self.document_container.content = widget def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) diff --git a/cocoa/src/toga_cocoa/widgets/splitcontainer.py b/cocoa/src/toga_cocoa/widgets/splitcontainer.py index 385bde9816..1591befa64 100644 --- a/cocoa/src/toga_cocoa/widgets/splitcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/splitcontainer.py @@ -1,7 +1,7 @@ from rubicon.objc import objc_method, objc_property from travertino.size import at_least -from toga_cocoa.containers import Container +from toga_cocoa.container import Container from toga_cocoa.libs import NSObject, NSSize, NSSplitView from .base import Widget @@ -64,8 +64,7 @@ def create(self): def add_content(self, position, widget, flex): # TODO: add flex option to the implementation - container = Container() - widget.container = container + container = Container(content=widget) # Turn the autoresizing mask on the widget into constraints. # This makes the widget fill the available space inside the diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 5bdf9239ad..3cf2f1b04a 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -1,5 +1,5 @@ from toga.command import Command as BaseCommand -from toga_cocoa.containers import Container, MinimumContainer +from toga_cocoa.container import Container, MinimumContainer from toga_cocoa.libs import ( SEL, NSBackingStoreBuffered, @@ -173,12 +173,11 @@ def create_toolbar(self): self.native.setToolbar(self._toolbar_native) def clear_content(self): - if self.interface.content: - self.interface.content._impl.container = None + pass def set_content(self, widget): - # Set the new widget's container to be the window's container - widget.container = self.container + # Set the content of the window's container + self.container.content = widget def get_title(self): return str(self.native.title) From ae3aa13187984d31cd8ab0a57f4cfabd2cdb4b62 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 13 Jun 2023 11:06:06 +0800 Subject: [PATCH 11/40] Refactor iOS to remove viewports. --- iOS/src/toga_iOS/app.py | 47 +------- iOS/src/toga_iOS/constraints.py | 11 +- iOS/src/toga_iOS/container.py | 194 ++++++++++++++++++++++++++++++ iOS/src/toga_iOS/widgets/base.py | 25 +--- iOS/src/toga_iOS/window.py | 88 ++------------ iOS/tests_backend/widgets/base.py | 9 +- 6 files changed, 225 insertions(+), 149 deletions(-) create mode 100644 iOS/src/toga_iOS/container.py diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index 0ea6c19581..aa26061735 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -1,15 +1,9 @@ import asyncio -from rubicon.objc import SEL, objc_method +from rubicon.objc import objc_method from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle -from toga_iOS.libs import ( - NSNotificationCenter, - UIKeyboardFrameEndUserInfoKey, - UIKeyboardWillHideNotification, - UIKeyboardWillShowNotification, - UIResponder, -) +from toga_iOS.libs import UIResponder from toga_iOS.window import Window @@ -41,22 +35,6 @@ def application_didFinishLaunchingWithOptions_( print("App finished launching.") App.app.native = application App.app.create() - - NSNotificationCenter.defaultCenter.addObserver( - self, - selector=SEL("keyboardWillShow:"), - name=UIKeyboardWillShowNotification, - object=None, - ) - NSNotificationCenter.defaultCenter.addObserver( - self, - selector=SEL("keyboardWillHide:"), - name=UIKeyboardWillHideNotification, - object=None, - ) - # Set the initial keyboard size. - App.app.interface.main_window.content._impl.viewport.kb_height = 0.0 - return True @objc_method @@ -71,27 +49,6 @@ def application_didChangeStatusBarOrientation_( and vice versa.""" App.app.interface.main_window.content.refresh() - @objc_method - def keyboardWillShow_(self, notification) -> None: - # Keyboard is about to be displayed. - # This will fire multiple times - once to display the keyboard, - # and again to display the autocomplete bar. - kb_height = App.app.interface.main_window._impl.controller.view.convertRect( - notification.userInfo.objectForKey( - UIKeyboardFrameEndUserInfoKey - ).CGRectValue, - fromView=None, - ).size.height - App.app.interface.main_window.content._impl.viewport.kb_height = kb_height - - App.app.interface.main_window.content.refresh() - - @objc_method - def keyboardWillHide_(self, notification) -> None: - # Reset the layout to the size of the screen. - App.app.interface.main_window.content._impl.viewport.kb_height = 0.0 - App.app.interface.main_window.content.refresh() - class App: def __init__(self, interface): diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index 70e733b4ef..e3336d3e6d 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -100,10 +100,9 @@ def container(self, value): self.container.native.addConstraint(self.height_constraint) def update(self, x, y, width, height): - if self.container: - # print(f"UPDATE CONSTRAINTS {self.widget} in {self.container} {width}x{height}@{x},{y}") - self.left_constraint.constant = x - self.top_constraint.constant = 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 - self.width_constraint.constant = width - self.height_constraint.constant = height + self.width_constraint.constant = width + self.height_constraint.constant = height diff --git a/iOS/src/toga_iOS/container.py b/iOS/src/toga_iOS/container.py new file mode 100644 index 0000000000..7aceafe107 --- /dev/null +++ b/iOS/src/toga_iOS/container.py @@ -0,0 +1,194 @@ +from .libs import ( + NSLayoutAttributeBottom, + NSLayoutAttributeLeft, + NSLayoutAttributeRight, + NSLayoutAttributeTop, + NSLayoutConstraint, + NSLayoutRelationGreaterThanOrEqual, + UIApplication, + UIColor, + UINavigationController, + UIView, + UIViewController, +) + + +class BaseContainer: + def __init__(self, content=None, on_refresh=None): + """A base class for iOS containers. + + :param content: The widget impl that is the container's initial content. + :param on_refresh: The callback to be notified when this container's layout is + refreshed. + """ + self._content = content + self.on_refresh = on_refresh + + # iOS renders everything at 96dpi. + self.dpi = 96 + self.baseline_dpi = self.dpi + + @property + def content(self): + """The Toga implementation widget that is the root content of this container. + + All children of the root content will also be added to the container as a result + of assigning content. + + If the container already has content, the old content will be replaced. The old + root content and all it's children will be removed from the container. + """ + return self._content + + @content.setter + def content(self, widget): + if self._content: + self._content.container = None + + self._content = widget + if widget: + widget.container = self + + def refreshed(self): + if self.on_refresh: + self.on_refresh() + + +class Container(BaseContainer): + def __init__( + self, + content=None, + min_width=100, + min_height=100, + layout_native=None, + on_refresh=None, + ): + """ + :param content: The widget impl that is the container's initial content. + :param min_width: The minimum width to enforce on the container + :param min_height: The minimum height to enforce on the container + :param layout_native: The native widget that should be used to provide size + hints to the layout. This will usually be the container widget itself; + however, for widgets like ScrollContainer where the layout needs to be + computed based on a different size to what will be rendered, the source of + the size can be different. + :param on_refresh: The callback to be notified when this container's layout is + refreshed. + """ + super().__init__(content=content, on_refresh=on_refresh) + self.native = UIView.alloc().init() + self.native.translatesAutoresizingMaskIntoConstraints = True + + self.layout_native = self.native if layout_native is None else layout_native + + try: + # 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 + + # Enforce a minimum size based on the content size. + # This is enforcing the *minimum* size; the container might actually be + # bigger. If the window is resizable, using >= allows the window to + # be dragged larger; if not resizable, it enforces the smallest + # size that can be programmatically set on the window. + self._min_width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 + self.native, + NSLayoutAttributeRight, + NSLayoutRelationGreaterThanOrEqual, + self.native, + NSLayoutAttributeLeft, + 1.0, + min_width, + ) + self.native.addConstraint(self._min_width_constraint) + + self._min_height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 + self.native, + NSLayoutAttributeBottom, + NSLayoutRelationGreaterThanOrEqual, + self.native, + NSLayoutAttributeTop, + 1.0, + min_height, + ) + self.native.addConstraint(self._min_height_constraint) + + @property + def width(self): + return self.native.bounds.size.width + + @property + def height(self): + return self.native.bounds.size.height + + @property + def top_offset(self): + return 0 + + def set_min_width(self, width): + self._min_width_constraint.constant = width + + def set_min_height(self, height): + self._min_height_constraint.constant = height + + +class RootContainer(Container): + def __init__( + self, + content=None, + min_width=100, + min_height=100, + layout_native=None, + on_refresh=None, + ): + """ + :param content: The widget impl that is the container's initial content. + :param min_width: The minimum width to enforce on the container + :param min_height: The minimum height to enforce on the container + :param layout_native: The native widget that should be used to provide + size hints to the layout. This will usually be the container widget + itself; however, for widgets like ScrollContainer where the layout + needs to be computed based on a different size to what will be + rendered, the source of the size can be different. + :param on_refresh: The callback to be notified when this container's + layout is refreshed. + """ + super().__init__( + content=content, + min_width=min_width, + min_height=min_height, + layout_native=layout_native, + on_refresh=on_refresh, + ) + + # Construct a NavigationController that provides a navigation bar, and + # is able to maintain a stack of navigable content. This is intialized + # with a root UIViewController that is the actual content + self.content_controller = UIViewController.alloc().init() + self.controller = UINavigationController.alloc().initWithRootViewController( + self.content_controller + ) + + # Set the controller's view to be the root content widget + self.content_controller.view = self.native + + @property + def height(self): + return self.native.bounds.size.height - self.top_offset + + @property + def top_offset(self): + return ( + UIApplication.sharedApplication.statusBarFrame.size.height + + self.controller.navigationBar.frame.size.height + ) + + @property + def title(self): + return self.controller.topViewController.title + + @title.setter + def title(self, value): + self.controller.topViewController.title = value diff --git a/iOS/src/toga_iOS/widgets/base.py b/iOS/src/toga_iOS/widgets/base.py index fc0a866e12..e52bf94858 100644 --- a/iOS/src/toga_iOS/widgets/base.py +++ b/iOS/src/toga_iOS/widgets/base.py @@ -11,7 +11,6 @@ def __init__(self, interface): self.interface = interface self.interface._impl = self self._container = None - self._viewport = None self.constraints = None self.native = None self.create() @@ -34,7 +33,7 @@ def container(self): @container.setter def container(self, container): if self.container: - assert container is None, "Widget already has a container" + assert container is None, f"{self} already has a container" # Existing container should be removed self.constraints.container = None @@ -53,11 +52,7 @@ def container(self, container): @property def viewport(self): - return self._viewport - - @viewport.setter - def viewport(self, viewport): - self._viewport = viewport + return self._container def get_enabled(self): return self.native.isEnabled() @@ -82,13 +77,8 @@ 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 - elif self.viewport: - offset_y = self.viewport.top_offset - self.constraints.update(x, y + offset_y, width, height) + # print("SET BOUNDS", self, x, y, width, height, self.container.top_offset) + self.constraints.update(x, y + self.container.top_offset, width, height) def set_alignment(self, alignment): pass @@ -121,13 +111,8 @@ def set_background_color_simple(self, value): self.native.backgroundColor = UIColor.whiteColor # INTERFACE - def add_child(self, child): - if self.viewport: - # we are the top level UIView - child.container = self - else: - child.container = self.container + child.container = self.container def insert_child(self, index, child): self.add_child(child) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 6dd830f4ca..45cd429200 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -1,58 +1,10 @@ +from toga_iOS.container import RootContainer from toga_iOS.libs import ( - UIApplication, - UINavigationController, UIScreen, - UIViewController, UIWindow, ) -class iOSViewport: - def __init__(self, widget): - self.widget = widget - # iOS renders everything at 96dpi. - self.dpi = 96 - self.baseline_dpi = self.dpi - - self.kb_height = 0.0 - - @property - def statusbar_height(self): - # This is the height of the status bar frame. - # If the status bar isn't visible (e.g., on iPhones in landscape orientation) - # the size will be 0. - return UIApplication.sharedApplication.statusBarFrame.size.height - - @property - def navbar_height(self): - try: - return ( - self.widget.controller.navigationController.navigationBar.frame.size.height - ) - except AttributeError: - return 0 - - @property - def top_offset(self): - return self.statusbar_height + self.navbar_height - - @property - def bottom_offset(self): - return self.kb_height - - @property - def width(self): - return self.widget.native.bounds.size.width - - @property - def height(self): - # Remove the height of the keyboard and the titlebar - # from the available viewport height - return ( - self.widget.native.bounds.size.height - self.bottom_offset - self.top_offset - ) - - class Window: def __init__(self, interface, title, position, size): self.interface = interface @@ -60,43 +12,29 @@ def __init__(self, interface, title, position, size): self.native = UIWindow.alloc().initWithFrame(UIScreen.mainScreen.bounds) - # The window has a UINavigationController to provide the navigation bar, - # and provide a stack of navigable content; this is initialized with a - # UIViewController to contain the actual content - self.controller = UIViewController.alloc().init() + # Set up a container for the window's content + # RootContainer provides a titlebar for the window. + self.container = RootContainer() - self.navigation_controller = ( - UINavigationController.alloc().initWithRootViewController(self.controller) - ) - self.native.rootViewController = self.navigation_controller + # Set the size of the content to the size of the window + self.container.native.frame = self.native.bounds + + # Set the window's root controller to be the container's controller + self.native.rootViewController = self.container.controller self.set_title(title) def clear_content(self): - if self.interface.content: - for child in self.interface.content.children: - child._impl.container = None + pass def set_content(self, widget): - widget.viewport = iOSViewport(self) - - # Add all children to the content widget. - for child in widget.interface.children: - child._impl.container = widget - - # Set the controller's view to be the new content widget - self.controller.view = widget.native - - # The main window content needs to use the autoresizing mask so - # that it fills all the available space in the view. - widget.native.translatesAutoresizingMaskIntoConstraints = True + self.container.content = widget def get_title(self): - return str(self.navigation_controller.title) + return str(self.container.title) def set_title(self, title): - # The title is set on the controller of the topmost content - self.navigation_controller.topViewController.title = title + self.container.title = title def get_position(self): return 0, 0 diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index a8d3011069..a04785dd28 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -37,11 +37,14 @@ def __init__(self, widget): super().__init__() self.app = widget.app self.widget = widget + self.impl = widget._impl self.native = widget._impl.native assert isinstance(self.native, self.native_class) def assert_container(self, container): - container_native = container._impl.native + assert container._impl.container == self.impl.container + + container_native = container._impl.container.native for control in container_native.subviews(): if control == self.native: break @@ -98,8 +101,8 @@ def assert_layout(self, size, 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 + nav_controller = self.widget.window._impl.native.rootViewController + navbar_frame = nav_controller.navigationBar.frame offset = statusbar_frame.size.height + navbar_frame.size.height assert ( self.native.frame.origin.x, From 3f34595ce7a7bb267222c480268f7ade74c10cff Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 13 Jun 2023 12:34:46 +0800 Subject: [PATCH 12/40] Initial working implementation of iOS ScrollContainer. --- iOS/src/toga_iOS/constraints.py | 2 + iOS/src/toga_iOS/container.py | 91 +-------- iOS/src/toga_iOS/widgets/base.py | 1 - iOS/src/toga_iOS/widgets/scrollcontainer.py | 212 +++++++++----------- iOS/src/toga_iOS/window.py | 9 + 5 files changed, 110 insertions(+), 205 deletions(-) diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index e3336d3e6d..12970e3417 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -16,6 +16,8 @@ def __init__(self, widget): :param widget: The Widget implementation to be constrained. """ self.widget = widget + self.widget.native.translatesAutoresizingMaskIntoConstraints = False + self._container = None self.width_constraint = None diff --git a/iOS/src/toga_iOS/container.py b/iOS/src/toga_iOS/container.py index 7aceafe107..6f71e45e35 100644 --- a/iOS/src/toga_iOS/container.py +++ b/iOS/src/toga_iOS/container.py @@ -1,12 +1,5 @@ from .libs import ( - NSLayoutAttributeBottom, - NSLayoutAttributeLeft, - NSLayoutAttributeRight, - NSLayoutAttributeTop, - NSLayoutConstraint, - NSLayoutRelationGreaterThanOrEqual, UIApplication, - UIColor, UINavigationController, UIView, UIViewController, @@ -14,15 +7,12 @@ class BaseContainer: - def __init__(self, content=None, on_refresh=None): + def __init__(self, content=None): """A base class for iOS containers. :param content: The widget impl that is the container's initial content. - :param on_refresh: The callback to be notified when this container's layout is - refreshed. """ self._content = content - self.on_refresh = on_refresh # iOS renders everything at 96dpi. self.dpi = 96 @@ -50,118 +40,53 @@ def content(self, widget): widget.container = self def refreshed(self): - if self.on_refresh: - self.on_refresh() + pass class Container(BaseContainer): - def __init__( - self, - content=None, - min_width=100, - min_height=100, - layout_native=None, - on_refresh=None, - ): + def __init__(self, content=None, layout_native=None): """ :param content: The widget impl that is the container's initial content. - :param min_width: The minimum width to enforce on the container - :param min_height: The minimum height to enforce on the container :param layout_native: The native widget that should be used to provide size hints to the layout. This will usually be the container widget itself; however, for widgets like ScrollContainer where the layout needs to be computed based on a different size to what will be rendered, the source of the size can be different. - :param on_refresh: The callback to be notified when this container's layout is - refreshed. """ - super().__init__(content=content, on_refresh=on_refresh) + super().__init__(content=content) self.native = UIView.alloc().init() self.native.translatesAutoresizingMaskIntoConstraints = True self.layout_native = self.native if layout_native is None else layout_native - try: - # 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 - - # Enforce a minimum size based on the content size. - # This is enforcing the *minimum* size; the container might actually be - # bigger. If the window is resizable, using >= allows the window to - # be dragged larger; if not resizable, it enforces the smallest - # size that can be programmatically set on the window. - self._min_width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - self.native, - NSLayoutAttributeRight, - NSLayoutRelationGreaterThanOrEqual, - self.native, - NSLayoutAttributeLeft, - 1.0, - min_width, - ) - self.native.addConstraint(self._min_width_constraint) - - self._min_height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - self.native, - NSLayoutAttributeBottom, - NSLayoutRelationGreaterThanOrEqual, - self.native, - NSLayoutAttributeTop, - 1.0, - min_height, - ) - self.native.addConstraint(self._min_height_constraint) - @property def width(self): - return self.native.bounds.size.width + return self.layout_native.bounds.size.width @property def height(self): - return self.native.bounds.size.height + return self.layout_native.bounds.size.height @property def top_offset(self): return 0 - def set_min_width(self, width): - self._min_width_constraint.constant = width - - def set_min_height(self, height): - self._min_height_constraint.constant = height - class RootContainer(Container): def __init__( self, content=None, - min_width=100, - min_height=100, layout_native=None, - on_refresh=None, ): """ :param content: The widget impl that is the container's initial content. - :param min_width: The minimum width to enforce on the container - :param min_height: The minimum height to enforce on the container :param layout_native: The native widget that should be used to provide size hints to the layout. This will usually be the container widget itself; however, for widgets like ScrollContainer where the layout needs to be computed based on a different size to what will be rendered, the source of the size can be different. - :param on_refresh: The callback to be notified when this container's - layout is refreshed. """ - super().__init__( - content=content, - min_width=min_width, - min_height=min_height, - layout_native=layout_native, - on_refresh=on_refresh, - ) + super().__init__(content=content, layout_native=layout_native) # Construct a NavigationController that provides a navigation bar, and # is able to maintain a stack of navigable content. This is intialized @@ -176,7 +101,7 @@ def __init__( @property def height(self): - return self.native.bounds.size.height - self.top_offset + return self.layout_native.bounds.size.height - self.top_offset @property def top_offset(self): diff --git a/iOS/src/toga_iOS/widgets/base.py b/iOS/src/toga_iOS/widgets/base.py index e52bf94858..7d147247fe 100644 --- a/iOS/src/toga_iOS/widgets/base.py +++ b/iOS/src/toga_iOS/widgets/base.py @@ -122,7 +122,6 @@ def remove_child(self, child): def add_constraints(self): self.constraints = Constraints(self) - self.native.translatesAutoresizingMaskIntoConstraints = False def refresh(self): self.rehint() diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index ecadc7e343..eadc370048 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -1,154 +1,124 @@ -from rubicon.objc import CGSizeMake +from rubicon.objc import SEL, NSMakePoint, NSMakeSize, objc_method, objc_property from travertino.size import at_least -from toga_iOS.libs import ( - NSLayoutAttributeBottom, - NSLayoutAttributeLeading, - NSLayoutAttributeTop, - NSLayoutAttributeTrailing, - NSLayoutConstraint, - NSLayoutRelationEqual, - UIColor, - UIScrollView, -) +from toga_iOS.container import Container +from toga_iOS.libs import UIColor, UIScrollView from toga_iOS.widgets.base import Widget -from toga_iOS.window import iOSViewport -class ScrollContainer(Widget): - def update_content_size(self): - # We need a layout pass to figure out how big the scrollable area should be - scrollable_content = self.interface.content._impl - scrollable_content.interface.refresh() - - content_width = 0 - padding_horizontal = 0 - content_height = 0 - padding_vertical = 0 - - if self.interface.horizontal: - content_width = scrollable_content.interface.layout.width - padding_horizontal = ( - scrollable_content.interface.style.padding_left - + scrollable_content.interface.style.padding_right - ) - else: - content_width = self.native.frame.size.width - - if self.interface.vertical: - content_height = scrollable_content.interface.layout.height - padding_vertical = ( - scrollable_content.interface.style.padding_top - + scrollable_content.interface.style.padding_bottom - ) - else: - content_height = self.native.frame.size.height +class TogaScrollView(UIScrollView): + interface = objc_property(object, weak=True) + impl = objc_property(object, weak=True) - self.native.setContentSize_( - CGSizeMake( - content_width + padding_horizontal, - content_height + padding_vertical, - ) - ) + @objc_method + def scrollViewDidScroll_(self, scrollView) -> None: + self.interface.on_scroll(None) - def constrain_to_scrollview(self, widget): - # The scrollview should know the content size as long as the - # view contained has an intrinsic size and the constraints are - # not ambiguous in any axis. - view = widget.native - leading_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - view, - NSLayoutAttributeLeading, - NSLayoutRelationEqual, - self.native, - NSLayoutAttributeLeading, - 1.0, - 0, - ) - trailing_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - self.native, - NSLayoutAttributeTrailing, - NSLayoutRelationEqual, - view, - NSLayoutAttributeTrailing, - 1.0, - 0, - ) - top_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - view, - NSLayoutAttributeTop, - NSLayoutRelationEqual, - self.native, - NSLayoutAttributeTop, - 1.0, - 0, - ) - bottom_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 - self.native, - NSLayoutAttributeBottom, - NSLayoutRelationEqual, - view, - NSLayoutAttributeBottom, - 1.0, - 0, - ) - self.native.addConstraints_( - [leading_constraint, trailing_constraint, top_constraint, bottom_constraint] - ) + @objc_method + def refreshContent(self): + self.impl.refresh_content() + +class ScrollContainer(Widget): def create(self): - self.native = UIScrollView.alloc().init() - self.native.translatesAutoresizingMaskIntoConstraints = False - self.native.backgroundColor = UIColor.whiteColor + self.native = TogaScrollView.alloc().init() + self.native.interface = self.interface + self.native.impl = self + self.native.backgroundColor = UIColor.redColor + self.native.delegate = self.native + + # UIScrollView doesn't have a native ability to disable a scrolling direction; + # it's handled by controlling the scrollable area. + self._allow_horizontal = True + self._allow_vertical = True + + self.document_container = Container(layout_native=self.native) + self.native.addSubview(self.document_container.native) self.add_constraints() def set_content(self, widget): - if self.interface.content is not None: - self.interface.content._impl.native.removeFromSuperview() - self.native.addSubview(widget.native) - widget.viewport = iOSViewport(widget) + self.document_container.content = widget + + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) + + # Setting the bounds changes the constraints, but that doesn't mean + # the constraints have been fully applied. Schedule a refresh to be done + # as soon as possible in the future + self.native.performSelector( + SEL("refreshContent"), withObject=None, afterDelay=0 + ) + + def refresh_content(self): + width = self.native.frame.size.width + height = self.native.frame.size.height + + if self.interface._content: + self.interface._content.refresh() + if self.interface.horizontal: + width = max(self.interface.content.layout.width, width) - for child in widget.interface.children: - child._impl.container = widget + if self.interface.vertical: + height = max(self.interface.content.layout.height, height) - self.constrain_to_scrollview(widget) + self.native.contentSize = NSMakeSize(width, height) + + def get_vertical(self): + return self._allow_vertical def set_vertical(self, value): - if self.interface.content: - self.update_content_size() + self._allow_vertical = value + self.refresh_content() + + def get_horizontal(self): + return self._allow_horizontal def set_horizontal(self, value): - if self.interface.content: - self.update_content_size() + self._allow_horizontal = value + self.refresh_content() 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()") + def get_max_vertical_position(self): + return max( + 0, + self.native.contentSize.height - self.native.frame.size.height, + ) def get_vertical_position(self): - self.interface.factory.not_implemented( - "ScrollContainer.get_vertical_position()" - ) - return 0 + return self.native.contentOffset.y def set_vertical_position(self, vertical_position): - self.interface.factory.not_implemented( - "ScrollContainer.set_vertical_position()" + if vertical_position < 0: + vertical_position = 0 + else: + max_value = self.get_max_vertical_position() + if vertical_position > max_value: + vertical_position = max_value + + self.native.setContentOffset( + NSMakePoint(self.native.contentOffset.x, vertical_position), animated=True ) - def get_horizontal_position(self): - self.interface.factory.not_implemented( - "ScrollContainer.get_horizontal_position()" + def get_max_horizontal_position(self): + return max( + 0, + self.native.contentSize.width - self.native.frame.size.width, ) - return 0 + + def get_horizontal_position(self): + return self.native.contentOffset.x def set_horizontal_position(self, horizontal_position): - self.interface.factory.not_implemented( - "ScrollContainer.set_horizontal_position()" + if horizontal_position < 0: + horizontal_position = 0 + else: + max_value = self.get_max_horizontal_position() + if horizontal_position > max_value: + horizontal_position = max_value + + self.native.setContentOffset( + NSMakePoint(horizontal_position, self.native.contentOffset.y), animated=True ) diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 45cd429200..4158cc00d5 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -1,5 +1,6 @@ from toga_iOS.container import RootContainer from toga_iOS.libs import ( + UIColor, UIScreen, UIWindow, ) @@ -22,6 +23,14 @@ def __init__(self, interface, title, position, size): # Set the window's root controller to be the container's controller self.native.rootViewController = self.container.controller + # Set the background color of the root content. + try: + # 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 + self.set_title(title) def clear_content(self): From e317601b44605e993b9f152977aa6c63ccba1eac Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 13 Jun 2023 12:51:42 +0800 Subject: [PATCH 13/40] Normalize some implementation details between iOS and Cocoa. --- cocoa/src/toga_cocoa/constraints.py | 2 ++ cocoa/src/toga_cocoa/container.py | 2 +- cocoa/src/toga_cocoa/widgets/base.py | 1 - .../src/toga_cocoa/widgets/scrollcontainer.py | 25 +++++++++++-------- iOS/src/toga_iOS/container.py | 23 +++++++++++++---- iOS/src/toga_iOS/widgets/scrollcontainer.py | 24 ++++++++++++------ 6 files changed, 51 insertions(+), 26 deletions(-) diff --git a/cocoa/src/toga_cocoa/constraints.py b/cocoa/src/toga_cocoa/constraints.py index 50d8aa1daa..a250f23655 100644 --- a/cocoa/src/toga_cocoa/constraints.py +++ b/cocoa/src/toga_cocoa/constraints.py @@ -16,6 +16,8 @@ def __init__(self, widget): :param widget: The Widget implementation to be constrained. """ self.widget = widget + self.widget.native.translatesAutoresizingMaskIntoConstraints = False + self._container = None self.width_constraint = None diff --git a/cocoa/src/toga_cocoa/container.py b/cocoa/src/toga_cocoa/container.py index 43742e6560..db081d2fae 100644 --- a/cocoa/src/toga_cocoa/container.py +++ b/cocoa/src/toga_cocoa/container.py @@ -94,7 +94,7 @@ def __init__( :param on_refresh: The callback to be notified when this container's layout is refreshed. """ - super().__init__(on_refresh=on_refresh) + super().__init__(content=content, on_refresh=on_refresh) self.native = TogaView.alloc().init() self.layout_native = self.native if layout_native is None else layout_native diff --git a/cocoa/src/toga_cocoa/widgets/base.py b/cocoa/src/toga_cocoa/widgets/base.py index 39cd45b17d..a00cac47ac 100644 --- a/cocoa/src/toga_cocoa/widgets/base.py +++ b/cocoa/src/toga_cocoa/widgets/base.py @@ -110,7 +110,6 @@ def remove_child(self, child): child.container = None def add_constraints(self): - self.native.translatesAutoresizingMaskIntoConstraints = False self.constraints = Constraints(self) def refresh(self): diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index aeef3214cc..6607ddd607 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -8,7 +8,6 @@ NSMakeRect, NSNoBorder, NSNotificationCenter, - NSRunLoop, NSScrollView, NSScrollViewDidEndLiveScrollNotification, NSScrollViewDidLiveScrollNotification, @@ -25,6 +24,13 @@ class TogaScrollView(NSScrollView): def didScroll_(self, note) -> None: self.interface.on_scroll(None) + @objc_method + def refreshContent(self): + # Now that we have an updated size for the ScrollContainer, re-evaluate + # the size of the document content + if self.interface._content: + self.interface._content.refresh() + class ScrollContainer(Widget): def create(self): @@ -69,14 +75,11 @@ def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) # Setting the bounds changes the constraints, but that doesn't mean - # the constraints have been fully applied. Let the NSRunLoop tick once - # to ensure constraints are applied. - NSRunLoop.currentRunLoop.runUntilDate(None) - - # Now that we have an updated size for the ScrollContainer, re-evaluate - # the size of the document content - if self.interface._content: - self.interface._content.refresh() + # the constraints have been fully applied. Schedule a refresh to be done + # as soon as possible in the future + self.native.performSelector( + SEL("refreshContent"), withObject=None, afterDelay=0 + ) def content_refreshed(self): width = self.native.frame.size.width @@ -96,7 +99,7 @@ def get_vertical(self): def set_vertical(self, value): self.native.hasVerticalScroller = value # If the scroll container has content, we need to force a refresh - # to let the scroll container know how large it's content is. + # to let the scroll container know how large its content is. if self.interface.content: self.interface.refresh() @@ -106,7 +109,7 @@ def get_horizontal(self): def set_horizontal(self, value): self.native.hasHorizontalScroller = value # If the scroll container has content, we need to force a refresh - # to let the scroll container know how large it's content is. + # to let the scroll container know how large its content is. if self.interface.content: self.interface.refresh() diff --git a/iOS/src/toga_iOS/container.py b/iOS/src/toga_iOS/container.py index 6f71e45e35..2c9971ea61 100644 --- a/iOS/src/toga_iOS/container.py +++ b/iOS/src/toga_iOS/container.py @@ -7,12 +7,15 @@ class BaseContainer: - def __init__(self, content=None): + def __init__(self, content=None, on_refresh=None): """A base class for iOS containers. :param content: The widget impl that is the container's initial content. + :param on_refresh: The callback to be notified when this container's layout is + refreshed. """ self._content = content + self.on_refresh = on_refresh # iOS renders everything at 96dpi. self.dpi = 96 @@ -40,11 +43,12 @@ def content(self, widget): widget.container = self def refreshed(self): - pass + if self.on_refresh: + self.on_refresh() class Container(BaseContainer): - def __init__(self, content=None, layout_native=None): + def __init__(self, content=None, layout_native=None, on_refresh=None): """ :param content: The widget impl that is the container's initial content. :param layout_native: The native widget that should be used to provide size @@ -52,8 +56,10 @@ def __init__(self, content=None, layout_native=None): however, for widgets like ScrollContainer where the layout needs to be computed based on a different size to what will be rendered, the source of the size can be different. + :param on_refresh: The callback to be notified when this container's layout is + refreshed. """ - super().__init__(content=content) + super().__init__(content=content, on_refresh=on_refresh) self.native = UIView.alloc().init() self.native.translatesAutoresizingMaskIntoConstraints = True @@ -77,6 +83,7 @@ def __init__( self, content=None, layout_native=None, + on_refresh=None, ): """ :param content: The widget impl that is the container's initial content. @@ -85,8 +92,14 @@ def __init__( itself; however, for widgets like ScrollContainer where the layout needs to be computed based on a different size to what will be rendered, the source of the size can be different. + :param on_refresh: The callback to be notified when this container's layout is + refreshed. """ - super().__init__(content=content, layout_native=layout_native) + super().__init__( + content=content, + layout_native=layout_native, + on_refresh=on_refresh, + ) # Construct a NavigationController that provides a navigation bar, and # is able to maintain a stack of navigable content. This is intialized diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index eadc370048..94a4a3296a 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -2,7 +2,7 @@ from travertino.size import at_least from toga_iOS.container import Container -from toga_iOS.libs import UIColor, UIScrollView +from toga_iOS.libs import UIScrollView from toga_iOS.widgets.base import Widget @@ -16,7 +16,8 @@ def scrollViewDidScroll_(self, scrollView) -> None: @objc_method def refreshContent(self): - self.impl.refresh_content() + if self.interface._content: + self.interface._content.refresh() class ScrollContainer(Widget): @@ -24,7 +25,6 @@ def create(self): self.native = TogaScrollView.alloc().init() self.native.interface = self.interface self.native.impl = self - self.native.backgroundColor = UIColor.redColor self.native.delegate = self.native # UIScrollView doesn't have a native ability to disable a scrolling direction; @@ -32,7 +32,10 @@ def create(self): self._allow_horizontal = True self._allow_vertical = True - self.document_container = Container(layout_native=self.native) + self.document_container = Container( + layout_native=self.native, + on_refresh=self.content_refreshed, + ) self.native.addSubview(self.document_container.native) self.add_constraints() @@ -49,12 +52,11 @@ def set_bounds(self, x, y, width, height): SEL("refreshContent"), withObject=None, afterDelay=0 ) - def refresh_content(self): + def content_refreshed(self): width = self.native.frame.size.width height = self.native.frame.size.height if self.interface._content: - self.interface._content.refresh() if self.interface.horizontal: width = max(self.interface.content.layout.width, width) @@ -68,14 +70,20 @@ def get_vertical(self): def set_vertical(self, value): self._allow_vertical = value - self.refresh_content() + # If the scroll container has content, we need to force a refresh + # to let the scroll container know how large its content is. + if self.interface.content: + self.interface.refresh() def get_horizontal(self): return self._allow_horizontal def set_horizontal(self, value): self._allow_horizontal = value - self.refresh_content() + # If the scroll container has content, we need to force a refresh + # to let the scroll container know how large its content is. + if self.interface.content: + self.interface.refresh() def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) From 883f8fb248de269b4dbaf6e5f5edd02d6aa48368 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 13 Jun 2023 14:50:35 +0800 Subject: [PATCH 14/40] iOS ScrollContainer to 100% coverage. --- core/src/toga/widgets/scrollcontainer.py | 54 ++++++++++++---- iOS/src/toga_iOS/widgets/scrollcontainer.py | 63 ++++++++----------- iOS/tests_backend/widgets/base.py | 9 +++ iOS/tests_backend/widgets/scrollcontainer.py | 36 +++++++++++ testbed/tests/widgets/test_scrollcontainer.py | 22 ++++--- 5 files changed, 125 insertions(+), 59 deletions(-) create mode 100644 iOS/tests_backend/widgets/scrollcontainer.py diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index 8acf5894e9..4d7b3ee9c0 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -157,18 +157,10 @@ def horizontal_position(self) -> int | None: def horizontal_position(self, horizontal_position): if not self.horizontal: raise ValueError( - "Cannot set horizontal position when horizontal is not set." + "Cannot set horizontal position when horizontal scrolling is not enabled." ) - horizontal_position = int(horizontal_position) - if horizontal_position < 0: - horizontal_position = 0 - else: - max_value = self.max_horizontal_position - if horizontal_position > max_value: - horizontal_position = max_value - - self._impl.set_horizontal_position(horizontal_position) + self.position = (horizontal_position, self._impl.get_vertical_position()) @property def max_vertical_position(self) -> int | None: @@ -198,9 +190,45 @@ def vertical_position(self) -> int | None: @vertical_position.setter def vertical_position(self, vertical_position): if not self.vertical: - raise ValueError("Cannot set vertical position when vertical is not set.") + raise ValueError( + "Cannot set vertical position when vertical scrolling is not enabled." + ) + + self.position = (self._impl.get_horizontal_position(), vertical_position) + + @property + def position(self) -> tuple[int | None, int | None]: + """The current scroll position. + + If the value provided for either axis is negative, or greater than the maximum + position in that axis, the value will be clipped to the valid range. + + If scrolling is disabled in either axis, returns ``None`` as the current + position for that axis, and raises :any:`ValueError` if an attempt is made to + change the position. + """ + return (self.horizontal_position, self.vertical_position) + + @position.setter + def position(self, position): + if not self.vertical: + raise ValueError( + "Cannot set full position when vertical scrolling is not enabled." + ) + if not self.horizontal: + raise ValueError( + "Cannot set full position when horizontal scrolling is not enabled." + ) + + horizontal_position = int(position[0]) + if horizontal_position < 0: + horizontal_position = 0 + else: + max_value = self.max_horizontal_position + if horizontal_position > max_value: + horizontal_position = max_value - vertical_position = int(vertical_position) + vertical_position = int(position[1]) if vertical_position < 0: vertical_position = 0 else: @@ -208,4 +236,4 @@ def vertical_position(self, vertical_position): if vertical_position > max_value: vertical_position = max_value - self._impl.set_vertical_position(vertical_position) + self._impl.set_position(horizontal_position, vertical_position) diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index 94a4a3296a..80f17e4436 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -56,15 +56,21 @@ def content_refreshed(self): width = self.native.frame.size.width height = self.native.frame.size.height - if self.interface._content: - if self.interface.horizontal: - width = max(self.interface.content.layout.width, width) + if self.interface.horizontal: + width = max(self.interface.content.layout.width, width) - if self.interface.vertical: - height = max(self.interface.content.layout.height, height) + if self.interface.vertical: + height = max(self.interface.content.layout.height, height) self.native.contentSize = NSMakeSize(width, height) + def set_background_color(self, value): + self.set_background_color_simple(value) + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + def get_vertical(self): return self._allow_vertical @@ -85,9 +91,8 @@ def set_horizontal(self, value): if self.interface.content: 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) + def get_horizontal_position(self): + return int(self.native.contentOffset.x) def get_max_vertical_position(self): return max( @@ -95,38 +100,24 @@ def get_max_vertical_position(self): self.native.contentSize.height - self.native.frame.size.height, ) - def get_vertical_position(self): - return self.native.contentOffset.y - - def set_vertical_position(self, vertical_position): - if vertical_position < 0: - vertical_position = 0 - else: - max_value = self.get_max_vertical_position() - if vertical_position > max_value: - vertical_position = max_value - - self.native.setContentOffset( - NSMakePoint(self.native.contentOffset.x, vertical_position), animated=True - ) - def get_max_horizontal_position(self): return max( 0, self.native.contentSize.width - self.native.frame.size.width, ) - def get_horizontal_position(self): - return self.native.contentOffset.x - - def set_horizontal_position(self, horizontal_position): - if horizontal_position < 0: - horizontal_position = 0 + def get_vertical_position(self): + return int(self.native.contentOffset.y) + + def set_position(self, horizontal_position, vertical_position): + if ( + horizontal_position == self.get_horizontal_position() + and vertical_position == self.get_vertical_position() + ): + # iOS doesn't generate a scroll event unless the position actually changes. + # Treat all scroll position assignments as a change. + self.interface.on_scroll(None) else: - max_value = self.get_max_horizontal_position() - if horizontal_position > max_value: - horizontal_position = max_value - - self.native.setContentOffset( - NSMakePoint(horizontal_position, self.native.contentOffset.y), animated=True - ) + self.native.setContentOffset( + NSMakePoint(horizontal_position, vertical_position), animated=True + ) diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index a04785dd28..8040863724 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -1,3 +1,5 @@ +from rubicon.objc import ObjCClass + from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM from toga_iOS.libs import UIApplication @@ -32,6 +34,9 @@ UIControlEventAllEvents = 0xFFFFFFFF +CATransaction = ObjCClass("CATransaction") + + class SimpleProbe(BaseProbe): def __init__(self, widget): super().__init__() @@ -73,6 +78,10 @@ async def redraw(self, message=None): # Force a widget repaint self.widget.window.content._impl.native.layer.displayIfNeeded() + # Flush CoreAnimation; this ensures all animations are complete + # and all constraints have been evaluated. + CATransaction.flush() + await super().redraw(message=message) @property diff --git a/iOS/tests_backend/widgets/scrollcontainer.py b/iOS/tests_backend/widgets/scrollcontainer.py new file mode 100644 index 0000000000..a8f19a18b0 --- /dev/null +++ b/iOS/tests_backend/widgets/scrollcontainer.py @@ -0,0 +1,36 @@ +import asyncio + +from rubicon.objc import NSMakePoint + +from toga_iOS.libs import UIScrollView + +from .base import SimpleProbe + + +class ScrollContainerProbe(SimpleProbe): + native_class = UIScrollView + + @property + def has_content(self): + return len(self.impl.document_container.native.subviews()) > 0 + + @property + def document_height(self): + return self.native.contentSize.height + + @property + def document_width(self): + return self.native.contentSize.width + + async def scroll(self): + self.native.contentOffset = NSMakePoint(0, 600) + + async def wait_for_scroll_completion(self): + position = self.widget.position + current = None + # Iterate until 2 successive reads of the scroll position, + # 0.05s apart, return the same value + while position != current: + position = current + await asyncio.sleep(0.05) + current = self.widget.position diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 43160b19a7..3027fc0870 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -3,7 +3,7 @@ import pytest import toga -from toga.colors import CORNFLOWERBLUE, REBECCAPURPLE +from toga.colors import CORNFLOWERBLUE, REBECCAPURPLE, TRANSPARENT from toga.style.pack import COLUMN, ROW, Pack from .properties import ( # noqa: F401 @@ -31,7 +31,11 @@ async def content(): ) for i in range(0, 100) ], - style=Pack(direction=COLUMN), + style=Pack( + direction=COLUMN, + # Ensure we can see the background of the scroll container + background_color=TRANSPARENT, + ), ) return box @@ -226,10 +230,10 @@ async def test_horizontal_scroll(widget, probe, content, on_scroll): # clear any scroll events caused by setup on_scroll.reset_mock() - widget.horizontal_position = probe.height * 3 + widget.horizontal_position = probe.width * 3 await probe.wait_for_scroll_completion() - await probe.redraw("Scroll down 3 pages") - assert widget.horizontal_position == probe.height * 3 + await probe.redraw("Scroll right a little") + assert widget.horizontal_position == probe.width * 3 on_scroll.assert_called_with(widget) on_scroll.reset_mock() @@ -312,8 +316,7 @@ async def test_scroll_both(widget, probe, content, on_scroll): # clear any scroll events caused by setup on_scroll.reset_mock() - widget.horizontal_position = 1000 - widget.vertical_position = 2000 + widget.position = 1000, 2000 await probe.wait_for_scroll_completion() await probe.redraw("Scroll to mid document") assert widget.horizontal_position == 1000 @@ -321,8 +324,7 @@ async def test_scroll_both(widget, probe, content, on_scroll): on_scroll.assert_called_with(widget) on_scroll.reset_mock() - widget.horizontal_position = 0 - widget.vertical_position = 20000 + widget.position = 0, 20000 await probe.wait_for_scroll_completion() await probe.redraw("Scroll to bottom left") assert widget.horizontal_position == 0 @@ -330,7 +332,7 @@ async def test_scroll_both(widget, probe, content, on_scroll): on_scroll.assert_called_with(widget) on_scroll.reset_mock() - widget.horizontal_position = 10000 + widget.position = 10000, 20000 await probe.wait_for_scroll_completion() await probe.redraw("Scroll to bottom right") assert widget.horizontal_position == 2040 - probe.width From ceb26be970abf40dfa1a30427ac6f5ee84a9e467 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 13 Jun 2023 15:22:27 +0800 Subject: [PATCH 15/40] Updated core tests, cocoa and GTK to match iOS implementation. --- .../src/toga_cocoa/widgets/scrollcontainer.py | 16 +------- core/src/toga/widgets/scrollcontainer.py | 4 +- core/tests/widgets/test_scrollcontainer.py | 39 +++++++++++++++++-- .../src/toga_dummy/widgets/scrollcontainer.py | 18 +-------- gtk/src/toga_gtk/widgets/scrollcontainer.py | 8 +--- 5 files changed, 43 insertions(+), 42 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 6607ddd607..f8d9b9acdd 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -129,15 +129,6 @@ def get_max_vertical_position(self): def get_vertical_position(self): return int(self.native.contentView.bounds.origin.y) - def set_vertical_position(self, vertical_position): - new_position = NSMakePoint( - self.native.contentView.bounds.origin.x, - vertical_position, - ) - self.native.contentView.scrollToPoint(new_position) - self.native.reflectScrolledClipView(self.native.contentView) - self.interface.on_scroll(None) - def get_max_horizontal_position(self): return max( 0, @@ -150,11 +141,8 @@ def get_max_horizontal_position(self): def get_horizontal_position(self): return int(self.native.contentView.bounds.origin.x) - def set_horizontal_position(self, horizontal_position): - new_position = NSMakePoint( - horizontal_position, - self.native.contentView.bounds.origin.y, - ) + def set_position(self, horizontal_position, vertical_position): + new_position = NSMakePoint(horizontal_position, vertical_position) self.native.contentView.scrollToPoint(new_position) self.native.reflectScrolledClipView(self.native.contentView) self.interface.on_scroll(None) diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index 4d7b3ee9c0..4893861a82 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -213,11 +213,11 @@ def position(self) -> tuple[int | None, int | None]: def position(self, position): if not self.vertical: raise ValueError( - "Cannot set full position when vertical scrolling is not enabled." + "Cannot set scroll position when vertical scrolling is not enabled." ) if not self.horizontal: raise ValueError( - "Cannot set full position when horizontal scrolling is not enabled." + "Cannot set scroll position when horizontal scrolling is not enabled." ) horizontal_position = int(position[0]) diff --git a/core/tests/widgets/test_scrollcontainer.py b/core/tests/widgets/test_scrollcontainer.py index d9b1b3699c..b695291de4 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -297,9 +297,15 @@ def test_horizontal_position_when_not_horizontal(scroll_container): scroll_container.horizontal = False with pytest.raises( ValueError, - match=r"Cannot set horizontal position when horizontal is not set.", + match=r"Cannot set horizontal position when horizontal scrolling is not enabled.", ): - scroll_container.horizontal_position = 0.5 + scroll_container.horizontal_position = 37 + + with pytest.raises( + ValueError, + match=r"Cannot set scroll position when horizontal scrolling is not enabled.", + ): + scroll_container.position = (37, 42) @pytest.mark.parametrize( @@ -333,6 +339,31 @@ def test_set_vertical_position_when_not_vertical(scroll_container): scroll_container.vertical = False with pytest.raises( ValueError, - match=r"Cannot set vertical position when vertical is not set.", + match=r"Cannot set vertical position when vertical scrolling is not enabled.", + ): + scroll_container.vertical_position = 42 + + with pytest.raises( + ValueError, + match=r"Cannot set scroll position when vertical scrolling is not enabled.", ): - scroll_container.vertical_position = 0.5 + scroll_container.position = (37, 42) + + +@pytest.mark.parametrize( + "position, expected", + [ + ((37, 42), (37, 42)), + ((-100, 42), (0, 42)), # Clipped to minimum horizontal value + ((37, -100), (37, 0)), # Clipped to minimum vertical value + ((-100, -100), (0, 0)), # Clipped to minimum + ((1500, 42), (1000, 42)), # Clipped to maximum horizontal value + ((37, 2500), (37, 2000)), # Clipped to maximum vertical value + ((1500, 2500), (1000, 2000)), # Clipped to maximum + ], +) +def test_position(scroll_container, position, expected): + "The scroll position can be set (clipped if necessary) and retrieved" + scroll_container.position = position + + assert scroll_container.position == expected diff --git a/dummy/src/toga_dummy/widgets/scrollcontainer.py b/dummy/src/toga_dummy/widgets/scrollcontainer.py index 6d90e561a2..8f0fe32c2a 100644 --- a/dummy/src/toga_dummy/widgets/scrollcontainer.py +++ b/dummy/src/toga_dummy/widgets/scrollcontainer.py @@ -35,30 +35,16 @@ def set_horizontal(self, value): def set_on_scroll(self, on_scroll): self._set_value("on_scroll", on_scroll) - def set_horizontal_position(self, horizontal_position): - if horizontal_position < 0: - horizontal_position = 0 - elif horizontal_position > self.get_max_horizontal_position(): - horizontal_position = self.get_max_horizontal_position() - + def set_position(self, horizontal_position, vertical_position): self._set_value("horizontal_position", horizontal_position) + self._set_value("vertical_position", vertical_position) def get_horizontal_position(self): - if not self.get_horizontal(): - return None return self._get_value("horizontal_position", 0) def get_max_horizontal_position(self): return 1000 - def set_vertical_position(self, vertical_position): - if vertical_position < 0: - vertical_position = 0 - elif vertical_position > self.get_max_vertical_position(): - vertical_position = self.get_max_vertical_position() - - self._set_value("vertical_position", vertical_position) - def get_vertical_position(self): if not self.get_vertical(): return None diff --git a/gtk/src/toga_gtk/widgets/scrollcontainer.py b/gtk/src/toga_gtk/widgets/scrollcontainer.py index efd3d1ab27..3117122b97 100644 --- a/gtk/src/toga_gtk/widgets/scrollcontainer.py +++ b/gtk/src/toga_gtk/widgets/scrollcontainer.py @@ -73,10 +73,6 @@ def get_max_vertical_position(self): def get_vertical_position(self): return self.native.get_vadjustment().get_value() - def set_vertical_position(self, vertical_position): - self.native.get_vadjustment().set_value(vertical_position) - self.interface.on_scroll(None) - def get_max_horizontal_position(self): return max( 0, @@ -87,7 +83,7 @@ def get_max_horizontal_position(self): def get_horizontal_position(self): return self.native.get_hadjustment().get_value() - def set_horizontal_position(self, horizontal_position): - print(self.native.get_policy()) + def set_position(self, horizontal_position, vertical_position): self.native.get_hadjustment().set_value(horizontal_position) + self.native.get_vadjustment().set_value(vertical_position) self.interface.on_scroll(None) From dac11fbf5b62c02e37813970815a36ef5608eaee Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 13 Jun 2023 15:54:34 +0800 Subject: [PATCH 16/40] Remove unneeded dummy class. --- dummy/src/toga_dummy/container.py | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 dummy/src/toga_dummy/container.py diff --git a/dummy/src/toga_dummy/container.py b/dummy/src/toga_dummy/container.py deleted file mode 100644 index b5161448e7..0000000000 --- a/dummy/src/toga_dummy/container.py +++ /dev/null @@ -1,26 +0,0 @@ -from toga_dummy.utils import not_required_on - - -@not_required_on("gtk", "winforms", "android", "web") -class Constraints: - def __init__(self, widget): - pass - - @property - def widget(self): - pass - - @widget.setter - def widget(self, value): - pass - - @property - def container(self): - pass - - @container.setter - def container(self, value): - pass - - def update(self, x, y, width, height): - pass From 20ca03e1f052cef685a727560f99697c5212391d Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 14 Jun 2023 10:57:07 +0800 Subject: [PATCH 17/40] Minor docs cleanups. --- changes/1969.feature.rst | 2 +- .../api/containers/scrollcontainer.rst | 5 +++-- docs/reference/api/index.rst | 2 +- docs/reference/data/widgets_by_platform.csv | 2 +- docs/reference/images/ScrollContainer.png | Bin 12376 -> 12931 bytes 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/changes/1969.feature.rst b/changes/1969.feature.rst index 1c57c4e265..c0261629de 100644 --- a/changes/1969.feature.rst +++ b/changes/1969.feature.rst @@ -1 +1 @@ -The ScrollConatiner widget now has 100% test coverage, and complete API documentation. +The ScrollContainer widget now has 100% test coverage, and complete API documentation. diff --git a/docs/reference/api/containers/scrollcontainer.rst b/docs/reference/api/containers/scrollcontainer.rst index c7a2407508..c0bccd0616 100644 --- a/docs/reference/api/containers/scrollcontainer.rst +++ b/docs/reference/api/containers/scrollcontainer.rst @@ -1,8 +1,8 @@ Scroll Container ================ -A container widget that can display a layout larger that the area of the -container, with overflow controlled by scroll bars. +A container that can display a layout larger that the area of the container, with +overflow controlled by scroll bars. .. figure:: /reference/images/ScrollContainer.png :align: center @@ -31,3 +31,4 @@ Reference .. autoclass:: toga.widgets.scrollcontainer.ScrollContainer :members: :undoc-members: + :exclude-members: window, app diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index fa0a96002b..292f939951 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -58,7 +58,7 @@ Layout widgets Usage Description ==================================================================== ======================================================================== :doc:`Box ` A generic container for other widgets. Used to construct layouts. - :doc:`ScrollContainer ` A container widget that can display a layout larger that the area of + :doc:`ScrollContainer ` A container that can display a layout larger that the area of the container, with overflow controlled by scroll bars. :doc:`SplitContainer ` Split Container :doc:`OptionContainer ` Option Container diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index fc56f0114f..462401b614 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -24,7 +24,7 @@ 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,|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`,A container widget that can display a layout larger that the area of the container, with overflow controlled by scroll bars.,|b|,|b|,|b|,|b|,|b|, +ScrollContainer,Layout Widget,:class:`~toga.widgets.scrollcontainer.ScrollContainer`,A container that can display a layout larger that the area of the container, with overflow controlled by scroll bars.,|b|,|b|,|b|,|b|,|b|, SplitContainer,Layout Widget,:class:`~toga.widgets.splitcontainer.SplitContainer`,Split Container,|b|,|b|,|b|,,, OptionContainer,Layout Widget,:class:`~toga.widgets.optioncontainer.OptionContainer`,Option Container,|b|,|b|,|b|,,, App Paths,Resource,:class:~`toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|, diff --git a/docs/reference/images/ScrollContainer.png b/docs/reference/images/ScrollContainer.png index 0e7c063e92c496b5554ca1e8e0705f69544d7741..919121bf215abd7c966a11ed9adc3f1636c04444 100644 GIT binary patch literal 12931 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ð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 delta 12068 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 Date: Wed, 14 Jun 2023 11:24:46 +0800 Subject: [PATCH 18/40] Corrections to the handling of containers in the Dummy backend. --- core/tests/widgets/test_base.py | 40 +++++++++---------- dummy/src/toga_dummy/widgets/base.py | 3 ++ .../src/toga_dummy/widgets/scrollcontainer.py | 3 +- dummy/src/toga_dummy/window.py | 30 ++++++++++---- 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/core/tests/widgets/test_base.py b/core/tests/widgets/test_base.py index 91c34b00de..d9d53046d8 100644 --- a/core/tests/widgets/test_base.py +++ b/core/tests/widgets/test_base.py @@ -151,8 +151,8 @@ def test_add_child(widget): # The widget's layout has been refreshed assert_action_performed_with(widget, "refresh") - # The window's container gets a refresh notification - assert_action_performed_with(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_performed_with(window.content, "refresh") # App's widget index has been updated assert len(app.widgets) == 2 @@ -216,8 +216,8 @@ def test_add_multiple_children(widget): # There will be multiple refresh calls assert_action_performed_with(widget, "refresh") - # The window's container gets a refresh notification - assert_action_performed_with(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_performed_with(window.content, "refresh") # App's widget index has been updated assert len(app.widgets) == 4 @@ -374,8 +374,8 @@ def test_insert_child(widget): # The widget's layout has been refreshed assert_action_performed_with(widget, "refresh") - # The window's container gets a refresh notification - assert_action_performed_with(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_performed_with(window.content, "refresh") # App's widget index has been updated assert len(app.widgets) == 2 @@ -440,8 +440,8 @@ def test_insert_position(widget): # The widget's layout has been refreshed assert_action_performed_with(widget, "refresh") - # The window's container gets a refresh notification - assert_action_performed_with(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_performed_with(window.content, "refresh") # App's widget index has been updated assert len(app.widgets) == 4 @@ -493,8 +493,8 @@ def test_insert_bad_position(widget): # The widget's layout has been refreshed assert_action_performed_with(widget, "refresh") - # The window's container gets a refresh notification - assert_action_performed_with(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_performed_with(window.content, "refresh") # App's widget index has been updated assert len(app.widgets) == 2 @@ -623,8 +623,8 @@ def test_remove_child(widget): # The widget's layout has been refreshed assert_action_performed_with(widget, "refresh") - # The window's container gets a refresh notification - assert_action_performed_with(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_performed_with(window.content, "refresh") def test_remove_multiple_children(widget): @@ -674,8 +674,8 @@ def test_remove_multiple_children(widget): # The widget's layout has been refreshed assert_action_performed_with(widget, "refresh") - # The window's container gets a refresh notification - assert_action_performed_with(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_performed_with(window.content, "refresh") def test_clear_all_children(widget): @@ -726,8 +726,8 @@ def test_clear_all_children(widget): # The widget's layout has been refreshed assert_action_performed_with(widget, "refresh") - # The window's container gets a refresh notification - assert_action_performed_with(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_performed_with(window.content, "refresh") def test_clear_no_children(widget): @@ -750,8 +750,8 @@ def test_clear_no_children(widget): # The widget's layout has *not* been refreshed assert_action_not_performed(widget, "refresh") - # The window's container gets a refresh notification - assert_action_not_performed(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_not_performed(window.content, "refresh") def test_clear_leaf_node(): @@ -775,8 +775,8 @@ def test_clear_leaf_node(): # The widget's layout has *not* been refreshed assert_action_not_performed(leaf, "refresh") - # The window's container gets a refresh notification - assert_action_not_performed(window, "container refreshed") + # The window's content gets a refresh notification + assert_action_not_performed(window.content, "refresh") def test_remove_from_non_parent(widget): diff --git a/dummy/src/toga_dummy/widgets/base.py b/dummy/src/toga_dummy/widgets/base.py index b578c7e6dd..9a5602e222 100644 --- a/dummy/src/toga_dummy/widgets/base.py +++ b/dummy/src/toga_dummy/widgets/base.py @@ -14,6 +14,9 @@ def __init__(self, interface): def viewport(self): return self.container + def get_size(self): + return (37, 42) + def create(self): self._action("create Widget") diff --git a/dummy/src/toga_dummy/widgets/scrollcontainer.py b/dummy/src/toga_dummy/widgets/scrollcontainer.py index 8f0fe32c2a..3efef98397 100644 --- a/dummy/src/toga_dummy/widgets/scrollcontainer.py +++ b/dummy/src/toga_dummy/widgets/scrollcontainer.py @@ -7,7 +7,7 @@ class ScrollContainer(Widget): def create(self): self._action("create ScrollContainer") - self.scroll_container = Container(self) + self.scroll_container = Container() # Required to satisfy the scroll container def get_width(self): @@ -18,6 +18,7 @@ def get_height(self): return 4200 def set_content(self, widget): + self.scroll_container.content = widget self._action("set content", widget=widget) def get_vertical(self): diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index 94ec1fbbbb..782f6bd199 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -3,29 +3,45 @@ @not_required class Container: - def __init__(self, native): + def __init__(self, content=None): self.baseline_dpi = 96 self.dpi = 96 - self.native = native + # Prime the underlying storage before using setter + self._content = None + self.content = content + + @property + def content(self): + return self._content + + @content.setter + def content(self, value): + if self._content: + self._content.container = None + + self._content = value + if value: + value.container = self @property def width(self): - return self.native.get_size()[0] + return self._content.get_size()[0] @property def height(self): - return self.native.get_size()[1] + return self._content.get_size()[1] def refreshed(self): - self.native._action("container refreshed") + if self._content: + self.content.refresh() class Window(LoggedObject): def __init__(self, interface, title, position, size): super().__init__() self.interface = interface - self.container = Container(self) + self.container = Container() self.set_title(title) self.set_position(position) @@ -43,7 +59,7 @@ def clear_content(self): self._action("clear content") def set_content(self, widget): - widget.container = self.container + self.container.content = widget self._action("set content", widget=widget) self._set_value("content", widget) From c967ad49f52b394c23a84fdda337be1ca92efd99 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 19 Jun 2023 10:54:39 +0800 Subject: [PATCH 19/40] Correct some docs markup issues. --- docs/reference/api/containers/scrollcontainer.rst | 1 + docs/reference/data/widgets_by_platform.csv | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/api/containers/scrollcontainer.rst b/docs/reference/api/containers/scrollcontainer.rst index c0bccd0616..0048500b2f 100644 --- a/docs/reference/api/containers/scrollcontainer.rst +++ b/docs/reference/api/containers/scrollcontainer.rst @@ -6,6 +6,7 @@ overflow controlled by scroll bars. .. figure:: /reference/images/ScrollContainer.png :align: center + :width: 300px .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index e11fcde05e..91ee466223 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -24,7 +24,7 @@ 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,|y|,|y|,|y|,|y|,|y|, 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`,A container that can display a layout larger that the area of the container, with overflow controlled by scroll bars.,|b|,|b|,|b|,|b|,|b|, +ScrollContainer,Layout Widget,:class:`~toga.widgets.scrollcontainer.ScrollContainer`,"A container that can display a layout larger that the area of the container, with overflow controlled by scroll bars.",|b|,|b|,|b|,|b|,|b|, SplitContainer,Layout Widget,:class:`~toga.widgets.splitcontainer.SplitContainer`,Split Container,|b|,|b|,|b|,,, OptionContainer,Layout Widget,:class:`~toga.widgets.optioncontainer.OptionContainer`,Option Container,|b|,|b|,|b|,,, App Paths,Resource,:class:~`toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|, From 8b266b9d81776431bb63416f135f48f573f2edf7 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 28 Jun 2023 21:35:01 +0100 Subject: [PATCH 20/40] Add current / max position indicator to scrollbar example app --- .../scrollcontainer/scrollcontainer/app.py | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/examples/scrollcontainer/scrollcontainer/app.py b/examples/scrollcontainer/scrollcontainer/app.py index 3db45fd25f..b30408585a 100644 --- a/examples/scrollcontainer/scrollcontainer/app.py +++ b/examples/scrollcontainer/scrollcontainer/app.py @@ -24,27 +24,25 @@ class ScrollContainerApp(toga.App): def startup(self): main_box = toga.Box(style=Pack(direction=COLUMN)) - switch_box = toga.Box(style=Pack(direction=ROW)) - switch_box.add( - toga.Switch( - "vertical scrolling", - value=self.vscrolling, - on_change=self.handle_vscrolling, - ) + self.vswitch = toga.Switch( + "Vertical", + value=self.vscrolling, + on_change=self.handle_vscrolling, ) - switch_box.add( - toga.Switch( - "horizontal scrolling", - value=self.hscrolling, - on_change=self.handle_hscrolling, - ) + self.hswitch = toga.Switch( + "Horizontal", + value=self.hscrolling, + on_change=self.handle_hscrolling, + ) + main_box.add( + toga.Box(style=Pack(direction=ROW), children=[self.vswitch, self.hswitch]) ) - main_box.add(switch_box) box = toga.Box(style=Pack(direction=COLUMN, padding=10)) self.scroller = toga.ScrollContainer( horizontal=self.hscrolling, vertical=self.vscrolling, + on_scroll=self.on_scroll, style=Pack(flex=1), ) @@ -64,28 +62,28 @@ def startup(self): self.toggle_up, "Toggle Up", shortcut=toga.Key.MOD_1 + toga.Key.UP, - group=toga.Group.VIEW, + group=toga.Group.COMMANDS, order=1, ), toga.Command( self.toggle_down, "Toggle Down", shortcut=toga.Key.MOD_1 + toga.Key.DOWN, - group=toga.Group.VIEW, + group=toga.Group.COMMANDS, order=2, ), toga.Command( self.toggle_left, "Toggle Left", shortcut=toga.Key.MOD_1 + toga.Key.LEFT, - group=toga.Group.VIEW, + group=toga.Group.COMMANDS, order=3, ), toga.Command( self.toggle_right, "Toggle Right", shortcut=toga.Key.MOD_1 + toga.Key.RIGHT, - group=toga.Group.VIEW, + group=toga.Group.COMMANDS, order=4, ), ) @@ -98,6 +96,14 @@ def handle_vscrolling(self, widget): self.vscrolling = widget.value self.scroller.vertical = self.vscrolling + def on_scroll(self, scroller): + self.hswitch.text = "Horizontal " + ( + f"({scroller.horizontal_position} / {scroller.max_horizontal_position})" + ) + self.vswitch.text = "Vertical " + ( + f"({scroller.vertical_position} / {scroller.max_vertical_position})" + ) + def toggle_up(self, widget): if not self.vscrolling: return From ffefeac74f8796901695d7622aa32f70b1385054 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 07:55:36 +0800 Subject: [PATCH 21/40] Loosen error handling when horizontal/vertical scrolling is disabled. --- .../src/toga_cocoa/widgets/scrollcontainer.py | 8 ++ core/src/toga/widgets/scrollcontainer.py | 91 ++++++-------- core/tests/widgets/test_scrollcontainer.py | 95 +++++++++++---- .../src/toga_dummy/widgets/scrollcontainer.py | 17 ++- iOS/src/toga_iOS/widgets/scrollcontainer.py | 8 ++ testbed/tests/widgets/test_scrollcontainer.py | 111 ++++++++++++++++-- 6 files changed, 236 insertions(+), 94 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index f8d9b9acdd..eb272f73ff 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -103,6 +103,10 @@ def set_vertical(self, value): if self.interface.content: self.interface.refresh() + # Disabling scrolling implies a position reset; that's a scroll event. + if value is False: + self.interface.on_scroll(None) + def get_horizontal(self): return self.native.hasHorizontalScroller @@ -113,6 +117,10 @@ def set_horizontal(self, value): if self.interface.content: self.interface.refresh() + # Disabling scrolling implies a position reset; that's a scroll event. + if value is False: + self.interface.on_scroll(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/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index a9dabc9910..b79f4795f5 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -129,29 +129,21 @@ def on_scroll(self, on_scroll): self._on_scroll = wrapped_handler(self, on_scroll) @property - def max_horizontal_position(self) -> int | None: - """The maximum horizontal scroll position. - - Returns ``None`` if horizontal scrolling is disabled. - """ - if not self.horizontal: - return None + def max_horizontal_position(self) -> int: + """The maximum horizontal scroll position.""" return self._impl.get_max_horizontal_position() @property - def horizontal_position(self) -> int | None: + def horizontal_position(self) -> int: """The current horizontal scroll position. - If the value provided is negative, or greater than the maximum - horizontal position, the value will be clipped to the valid range. + If the value provided is negative, or greater than the maximum horizontal + position, the value will be clipped to the valid range. - :returns: The current horizontal scroll position, or :any:`None` if horizontal - scrolling is disabled. - :raises ValueError: If an attempt is made to change the horizontal position - when horizontal scrolling is disabled. + :returns: The current horizontal scroll position. + :raises ValueError: If an attempt is made to change the horizontal position when + horizontal scrolling is disabled. """ - if not self.horizontal: - return None return self._impl.get_horizontal_position() @horizontal_position.setter @@ -164,29 +156,21 @@ def horizontal_position(self, horizontal_position): self.position = (horizontal_position, self._impl.get_vertical_position()) @property - def max_vertical_position(self) -> int | None: - """The maximum vertical scroll position. - - Returns ``None`` if vertical scrolling is disabled. - """ - if not self.vertical: - return None + def max_vertical_position(self) -> int: + """The maximum vertical scroll position.""" return self._impl.get_max_vertical_position() @property - def vertical_position(self) -> int | None: + def vertical_position(self) -> int: """The current vertical scroll position. If the value provided is negative, or greater than the maximum vertical position, the value will be clipped to the valid range. - :returns: The current vertical scroll position, or :any:`None` if vertical - scrolling is disabled. + :returns: The current vertical scroll position. :raises ValueError: If an attempt is made to change the vertical position when vertical scrolling is disabled. """ - if not self.vertical: - return None return self._impl.get_vertical_position() @vertical_position.setter @@ -199,45 +183,42 @@ def vertical_position(self, vertical_position): self.position = (self._impl.get_horizontal_position(), vertical_position) @property - def position(self) -> tuple[int | None, int | None]: + def position(self) -> tuple[int, int]: """The current scroll position. If the value provided for either axis is negative, or greater than the maximum position in that axis, the value will be clipped to the valid range. + If scrolling is disabled in either axis, the value provided for the scroll position + in that axis will be ignored. + :returns: A tuple containing the current scroll position in the horizontal and - vertical axis. A value of :any:`None` is returned if scrolling is disabled - in that axis. - :raises ValueError: If an attempt is made to change the position when scrolling - in either axis is disabled. + vertical axis. """ return (self.horizontal_position, self.vertical_position) @position.setter def position(self, position): - if not self.vertical: - raise ValueError( - "Cannot set scroll position when vertical scrolling is not enabled." - ) - if not self.horizontal: - raise ValueError( - "Cannot set scroll position when horizontal scrolling is not enabled." - ) - - horizontal_position = int(position[0]) - if horizontal_position < 0: - horizontal_position = 0 + if self.horizontal: + horizontal_position = int(position[0]) + if horizontal_position < 0: + horizontal_position = 0 + else: + max_value = self.max_horizontal_position + if horizontal_position > max_value: + horizontal_position = max_value else: - max_value = self.max_horizontal_position - if horizontal_position > max_value: - horizontal_position = max_value - - vertical_position = int(position[1]) - if vertical_position < 0: - vertical_position = 0 + horizontal_position = self.horizontal_position + + if self.vertical: + vertical_position = int(position[1]) + if vertical_position < 0: + vertical_position = 0 + else: + max_value = self.max_vertical_position + if vertical_position > max_value: + vertical_position = max_value else: - max_value = self.max_vertical_position - if vertical_position > max_value: - vertical_position = max_value + vertical_position = self.vertical_position self._impl.set_position(horizontal_position, vertical_position) diff --git a/core/tests/widgets/test_scrollcontainer.py b/core/tests/widgets/test_scrollcontainer.py index b695291de4..629fa50ffa 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -235,11 +235,16 @@ def test_clear_content(app, window, scroll_container, content): (object(), True), ], ) -def test_horizontal(scroll_container, content, value, expected): +def test_horizontal(scroll_container, on_scroll_handler, content, value, expected): "Horizontal scrolling can be enabled/disabled." scroll_container.horizontal = value scroll_container.horizontal == expected + if not expected: + on_scroll_handler.assert_called_with(scroll_container) + else: + on_scroll_handler.assert_not_called() + # Content is refreshed as a result of the change assert_action_performed(content, "refresh") @@ -257,11 +262,16 @@ def test_horizontal(scroll_container, content, value, expected): (object(), True), ], ) -def test_vertical(scroll_container, content, value, expected): +def test_vertical(scroll_container, on_scroll_handler, content, value, expected): "Vertical scrolling can be enabled/disabled." scroll_container.vertical = value scroll_container.vertical == expected + if not expected: + on_scroll_handler.assert_called_with(scroll_container) + else: + on_scroll_handler.assert_not_called() + # Content is refreshed as a result of the change assert_action_performed(content, "refresh") @@ -276,20 +286,38 @@ def test_vertical(scroll_container, content, value, expected): (10.1, 10), # Float, converted to int ], ) -def test_horizontal_position(scroll_container, position, expected): +def test_horizontal_position(scroll_container, on_scroll_handler, position, expected): "The horizontal position can be set (clipped if necessary) and retrieved" scroll_container.horizontal_position = position + # scroll handler fired + on_scroll_handler.assert_called_with(scroll_container) + assert scroll_container.horizontal_position == expected assert scroll_container.max_horizontal_position == 1000 -def test_get_horizontal_position_when_not_horizontal(scroll_container): - "If horizontal scrolling isn't enabled, getting the horizontal position raises an error" +def test_disable_horizontal_scrolling(scroll_container, on_scroll_handler): + "When disabling horizontal scrolling, horizontal position resets" + scroll_container.horizontal_position = 100 + on_scroll_handler.reset_mock() + scroll_container.horizontal = False - assert scroll_container.horizontal_position is None - assert scroll_container.max_horizontal_position is None + # scroll handler fired as a result of reset + on_scroll_handler.assert_called_with(scroll_container) + + assert scroll_container.horizontal_position == 0 + assert scroll_container.max_horizontal_position == 0 + + # Vertical position is unaffected by horizontal setting + scroll_container.vertical_position = 100 + + assert scroll_container.vertical_position == 100 + + # scroll handler fired + on_scroll_handler.assert_called_with(scroll_container) + on_scroll_handler.reset_mock() def test_horizontal_position_when_not_horizontal(scroll_container): @@ -301,11 +329,11 @@ def test_horizontal_position_when_not_horizontal(scroll_container): ): scroll_container.horizontal_position = 37 - with pytest.raises( - ValueError, - match=r"Cannot set scroll position when horizontal scrolling is not enabled.", - ): - scroll_container.position = (37, 42) + # horizontal coordinate is ignored when setting a full position + scroll_container.position = (37, 42) + + assert scroll_container.horizontal_position == 0 + assert scroll_container.vertical_position == 42 @pytest.mark.parametrize( @@ -318,20 +346,38 @@ def test_horizontal_position_when_not_horizontal(scroll_container): (10.1, 10), # Float, converted to int ], ) -def test_vertical_position(scroll_container, position, expected): +def test_vertical_position(scroll_container, on_scroll_handler, position, expected): "The vertical position can be set (clipped if necessary) and retrieved" scroll_container.vertical_position = position + # scroll handler fired + on_scroll_handler.assert_called_with(scroll_container) + assert scroll_container.vertical_position == expected assert scroll_container.max_vertical_position == 2000 -def test_get_vertical_position_when_not_vertical(scroll_container): - "If vertical scrolling isn't enabled, getting the vertical position raises an error" +def test_disable_vertical_scrolling(scroll_container, on_scroll_handler): + "When vertical scrolling is disabled, vertical position resets" + scroll_container.vertical_position = 100 + scroll_container.vertical = False - assert scroll_container.vertical_position is None - assert scroll_container.max_vertical_position is None + # scroll handler fired as a result of reset + on_scroll_handler.assert_called_with(scroll_container) + on_scroll_handler.reset_mock() + + assert scroll_container.vertical_position == 0 + assert scroll_container.max_vertical_position == 0 + + # Horizontal position is unaffected by vertical setting + scroll_container.horizontal_position = 100 + + assert scroll_container.horizontal_position == 100 + + # scroll handler fired + on_scroll_handler.assert_called_with(scroll_container) + on_scroll_handler.reset_mock() def test_set_vertical_position_when_not_vertical(scroll_container): @@ -343,11 +389,11 @@ def test_set_vertical_position_when_not_vertical(scroll_container): ): scroll_container.vertical_position = 42 - with pytest.raises( - ValueError, - match=r"Cannot set scroll position when vertical scrolling is not enabled.", - ): - scroll_container.position = (37, 42) + # vertical coordinate is ignored when setting a full position + scroll_container.position = (37, 42) + + assert scroll_container.horizontal_position == 37 + assert scroll_container.vertical_position == 0 @pytest.mark.parametrize( @@ -362,8 +408,11 @@ def test_set_vertical_position_when_not_vertical(scroll_container): ((1500, 2500), (1000, 2000)), # Clipped to maximum ], ) -def test_position(scroll_container, position, expected): +def test_position(scroll_container, on_scroll_handler, position, expected): "The scroll position can be set (clipped if necessary) and retrieved" scroll_container.position = position assert scroll_container.position == expected + + # scroll handler fired + on_scroll_handler.assert_called_with(scroll_container) diff --git a/dummy/src/toga_dummy/widgets/scrollcontainer.py b/dummy/src/toga_dummy/widgets/scrollcontainer.py index 3efef98397..5944499d22 100644 --- a/dummy/src/toga_dummy/widgets/scrollcontainer.py +++ b/dummy/src/toga_dummy/widgets/scrollcontainer.py @@ -27,29 +27,38 @@ def get_vertical(self): def set_vertical(self, value): self._set_value("vertical", value) + # Disabling scrolling implies a position reset; that's a scroll event. + if value is False: + self._set_value("vertical_position", 0) + self.interface.on_scroll(None) + def get_horizontal(self): return self._get_value("horizontal", True) def set_horizontal(self, value): self._set_value("horizontal", value) + # Disabling scrolling implies a position reset; that's a scroll event. + if value is False: + self._set_value("horizontal_position", 0) + self.interface.on_scroll(None) + def set_on_scroll(self, on_scroll): self._set_value("on_scroll", on_scroll) def set_position(self, horizontal_position, vertical_position): self._set_value("horizontal_position", horizontal_position) self._set_value("vertical_position", vertical_position) + self.interface.on_scroll(None) def get_horizontal_position(self): return self._get_value("horizontal_position", 0) def get_max_horizontal_position(self): - return 1000 + return 1000 if self.get_horizontal() else 0 def get_vertical_position(self): - if not self.get_vertical(): - return None return self._get_value("vertical_position", 0) def get_max_vertical_position(self): - return 2000 + return 2000 if self.get_vertical() else 0 diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index 80f17e4436..08979f8c56 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -81,6 +81,10 @@ def set_vertical(self, value): if self.interface.content: self.interface.refresh() + # Disabling scrolling implies a position reset; that's a scroll event. + if value is False: + self.interface.on_scroll(None) + def get_horizontal(self): return self._allow_horizontal @@ -91,6 +95,10 @@ def set_horizontal(self, value): if self.interface.content: self.interface.refresh() + # Disabling scrolling implies a position reset; that's a scroll event. + if value is False: + self.interface.on_scroll(None) + def get_horizontal_position(self): return int(self.native.contentOffset.x) diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 3027fc0870..30bc15df9c 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -93,49 +93,136 @@ async def test_clear_content(widget, probe, small_content): async def test_enable_horizontal_scrolling(widget, probe, content, on_scroll): "Horizontal scrolling can be disabled" - content.style.direction = ROW + # Add some wide content + content.insert( + 0, + toga.Label( + "This is a long label", + style=Pack( + width=2000, + background_color=CORNFLOWERBLUE, + padding=20, + height=20, + ), + ), + ) + # clear any scroll events caused by setup + on_scroll.reset_mock() + + # Disable horizontal scrolling widget.horizontal = False await probe.redraw("Horizontal scrolling is disabled") - assert widget.horizontal_position is None - assert widget.max_horizontal_position is None + assert widget.horizontal_position == 0 + assert widget.max_horizontal_position == 0 + + # Setting *just* the horizontal position is an error with pytest.raises(ValueError): widget.horizontal_position = 120 - widget.horizontal = True - await probe.redraw("Horizontal scrolling is enabled") + # If setting a *full* position, the horizontal coordinate is ignored. + widget.position = (120, 200) + await probe.wait_for_scroll_completion() + await probe.redraw("Horizontal scroll distance is ignored") - # clear any scroll events caused by setup + assert widget.horizontal_position == 0 + assert widget.vertical_position == 200 + on_scroll.assert_called_with(widget) + on_scroll.reset_mock() + + # If horizontal scrolling is disabled, you can still set the vertical position. + widget.vertical_position = 0 + await probe.wait_for_scroll_completion() + await probe.redraw("Vertical position has been set") + + assert widget.horizontal_position == 0 + assert widget.vertical_position == 0 + on_scroll.assert_called_with(widget) on_scroll.reset_mock() + widget.horizontal = True + await probe.redraw("Horizontal scrolling is enabled") + widget.horizontal_position = 120 await probe.wait_for_scroll_completion() await probe.redraw("Horizontal scroll was allowed") assert widget.horizontal_position == 120 on_scroll.assert_called_with(widget) + on_scroll.reset_mock() + + # Disabling horizontal scrolling resets horizontal position and emits an event. + widget.horizontal = False + await probe.wait_for_scroll_completion() + await probe.redraw("Horizontal scrolling is disabled again") + assert widget.horizontal_position == 0 + on_scroll.assert_called_with(widget) -async def test_enable_vertical_scrolling(widget, probe, on_scroll): +async def test_enable_vertical_scrolling(widget, probe, content, on_scroll): + "Vertical scrolling can be disabled" + # Add some wide content + content.insert( + 0, + toga.Label( + "This is a long label", + style=Pack( + width=2000, + background_color=CORNFLOWERBLUE, + padding=20, + height=20, + ), + ), + ) + + # clear any scroll events caused by setup + on_scroll.reset_mock() + widget.vertical = False await probe.redraw("Vertical scrolling is disabled") - assert widget.vertical_position is None - assert widget.max_vertical_position is None + assert widget.vertical_position == 0 + assert widget.max_vertical_position == 0 + + # Setting *just* the vertical position is an error with pytest.raises(ValueError): widget.vertical_position = 120 - widget.vertical = True - await probe.redraw("Vertical scrolling is enabled") + # If setting a *full* position, the horizontal coordinate is ignored. + widget.position = (120, 200) + await probe.wait_for_scroll_completion() + await probe.redraw("Vertical scroll distance is ignored") - # clear any scroll events caused by setup + assert widget.horizontal_position == 120 + assert widget.vertical_position == 0 + on_scroll.assert_called_with(widget) + on_scroll.reset_mock() + + # If vertical scrolling is disabled, you can still set the horizontal position. + widget.horizontal_position = 0 + await probe.wait_for_scroll_completion() + await probe.redraw("Horizontal position has been set") + + assert widget.horizontal_position == 0 + assert widget.vertical_position == 0 + on_scroll.assert_called_with(widget) on_scroll.reset_mock() + widget.vertical = True + await probe.redraw("Vertical scrolling is enabled") + widget.vertical_position = 120 await probe.wait_for_scroll_completion() await probe.redraw("Vertical scroll was allowed") assert widget.vertical_position == 120 on_scroll.assert_called_with(widget) + on_scroll.reset_mock() + + widget.vertical = False + await probe.wait_for_scroll_completion() + await probe.redraw("Vertical scrolling is disabled again") + assert widget.vertical_position == 0 + on_scroll.assert_called_with(widget) async def test_vertical_scroll(widget, probe, on_scroll): From 572b95e5cb1082293a244a92c977bb203c6efcac Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 08:16:47 +0800 Subject: [PATCH 22/40] Correct the GTK implementation of scrolling reset. --- cocoa/src/toga_cocoa/widgets/scrollcontainer.py | 4 ++-- gtk/src/toga_gtk/widgets/scrollcontainer.py | 14 ++++++++++++++ iOS/src/toga_iOS/widgets/scrollcontainer.py | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index eb272f73ff..ed46fdced3 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -104,7 +104,7 @@ def set_vertical(self, value): self.interface.refresh() # Disabling scrolling implies a position reset; that's a scroll event. - if value is False: + if not value: self.interface.on_scroll(None) def get_horizontal(self): @@ -118,7 +118,7 @@ def set_horizontal(self, value): self.interface.refresh() # Disabling scrolling implies a position reset; that's a scroll event. - if value is False: + if not value: self.interface.on_scroll(None) def rehint(self): diff --git a/gtk/src/toga_gtk/widgets/scrollcontainer.py b/gtk/src/toga_gtk/widgets/scrollcontainer.py index 3117122b97..0a4a44fb92 100644 --- a/gtk/src/toga_gtk/widgets/scrollcontainer.py +++ b/gtk/src/toga_gtk/widgets/scrollcontainer.py @@ -51,6 +51,10 @@ def set_horizontal(self, value): if self.interface.vertical else Gtk.PolicyType.NEVER, ) + # Disabling scrolling implies a position reset; that's a scroll event. + if not value: + self.native.get_hadjustment().set_value(0) + self.interface.on_scroll(None) def get_vertical(self): return self.native.get_policy()[1] == Gtk.PolicyType.AUTOMATIC @@ -62,8 +66,15 @@ def set_vertical(self, value): else Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC if value else Gtk.PolicyType.NEVER, ) + # Disabling scrolling implies a position reset; that's a scroll event. + if not value: + self.native.get_vadjustment().set_value(0) + self.interface.on_scroll(None) def get_max_vertical_position(self): + if not self.get_vertical(): + return 0 + return max( 0, self.native.get_vadjustment().get_upper() @@ -74,6 +85,9 @@ def get_vertical_position(self): return self.native.get_vadjustment().get_value() def get_max_horizontal_position(self): + if not self.get_horizontal(): + return 0 + return max( 0, self.native.get_hadjustment().get_upper() diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index 08979f8c56..34a5699a6f 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -82,7 +82,7 @@ def set_vertical(self, value): self.interface.refresh() # Disabling scrolling implies a position reset; that's a scroll event. - if value is False: + if not value: self.interface.on_scroll(None) def get_horizontal(self): @@ -96,7 +96,7 @@ def set_horizontal(self, value): self.interface.refresh() # Disabling scrolling implies a position reset; that's a scroll event. - if value is False: + if not value: self.interface.on_scroll(None) def get_horizontal_position(self): From e781c3cea62ff496f93835d6a7de82180f92f2ac Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 09:53:45 +0800 Subject: [PATCH 23/40] Corrected edge case seen in CI. --- cocoa/src/toga_cocoa/widgets/scrollcontainer.py | 4 ++++ iOS/src/toga_iOS/widgets/scrollcontainer.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index ed46fdced3..8680868a49 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -135,6 +135,8 @@ def get_max_vertical_position(self): ) def get_vertical_position(self): + if not self.get_vertical(): + return 0 return int(self.native.contentView.bounds.origin.y) def get_max_horizontal_position(self): @@ -147,6 +149,8 @@ def get_max_horizontal_position(self): ) def get_horizontal_position(self): + if not self.get_horizontal(): + return 0 return int(self.native.contentView.bounds.origin.x) def set_position(self, horizontal_position, vertical_position): diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index 34a5699a6f..8a129344d9 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -100,6 +100,8 @@ def set_horizontal(self, value): self.interface.on_scroll(None) def get_horizontal_position(self): + if not self.get_horizontal(): + return 0 return int(self.native.contentOffset.x) def get_max_vertical_position(self): @@ -115,6 +117,8 @@ def get_max_horizontal_position(self): ) def get_vertical_position(self): + if not self.get_vertical(): + return 0 return int(self.native.contentOffset.y) def set_position(self, horizontal_position, vertical_position): From 6887b261291336dc3b2699d050a2efa414c42fe3 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 29 Jun 2023 12:29:11 +0100 Subject: [PATCH 24/40] Android WIP --- android/src/toga_android/libs/android/view.py | 1 + android/src/toga_android/widgets/base.py | 5 + .../toga_android/widgets/scrollcontainer.py | 115 ++++++++++-------- android/src/toga_android/window.py | 3 + .../tests_backend/widgets/scrollcontainer.py | 44 +++++++ core/src/toga/widgets/scrollcontainer.py | 7 +- 6 files changed, 125 insertions(+), 50 deletions(-) create mode 100644 android/tests_backend/widgets/scrollcontainer.py diff --git a/android/src/toga_android/libs/android/view.py b/android/src/toga_android/libs/android/view.py index a35d3802d7..f86ed0d17c 100644 --- a/android/src/toga_android/libs/android/view.py +++ b/android/src/toga_android/libs/android/view.py @@ -10,6 +10,7 @@ ViewGroup__LayoutParams = JavaClass("android/view/ViewGroup$LayoutParams") View__MeasureSpec = JavaClass("android/view/View$MeasureSpec") View__OnFocusChangeListener = JavaInterface("android/view/View$OnFocusChangeListener") +View__OnScrollChangeListener = JavaInterface("android/view/View$OnScrollChangeListener") View__OnTouchListener = JavaInterface("android/view/View$OnTouchListener") ViewTreeObserver__OnGlobalLayoutListener = JavaInterface( "android/view/ViewTreeObserver$OnGlobalLayoutListener" diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 5673d4ba75..497e2d4cb5 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -84,6 +84,11 @@ def container(self, container): self.rehint() + @property + def scale(self): + viewport = self.viewport if self.viewport else self.container.viewport + return viewport.scale + def get_enabled(self): return self.native.isEnabled() diff --git a/android/src/toga_android/widgets/scrollcontainer.py b/android/src/toga_android/widgets/scrollcontainer.py index 59afbe76ab..ccdb545383 100644 --- a/android/src/toga_android/widgets/scrollcontainer.py +++ b/android/src/toga_android/widgets/scrollcontainer.py @@ -2,7 +2,12 @@ from toga_android.window import AndroidViewport -from ..libs.android.view import Gravity, View__MeasureSpec, View__OnTouchListener +from ..libs.android.view import ( + Gravity, + View__MeasureSpec, + View__OnScrollChangeListener, + View__OnTouchListener, +) from ..libs.android.widget import ( HorizontalScrollView, LinearLayout__LayoutParams, @@ -12,10 +17,9 @@ class TogaOnTouchListener(View__OnTouchListener): - is_scrolling_enabled = True - def __init__(self): super().__init__() + self.is_scrolling_enabled = True def onTouch(self, view, motion_event): if self.is_scrolling_enabled: @@ -24,23 +28,30 @@ def onTouch(self, view, motion_event): return True -class ScrollContainer(Widget): - vScrollListener = None - hScrollView = None - hScrollListener = None +class TogaOnScrollListener(View__OnScrollChangeListener): + def __init__(self, impl): + super().__init__() + self.impl = impl + + def onScrollChange(self, view, new_x, new_y, old_x, old_y): + self.impl.interface.on_scroll(None) + +class ScrollContainer(Widget): def create(self): - vScrollView = ScrollView(self._native_activity) + scroll_listener = TogaOnScrollListener(self) + + self.native = self.vScrollView = ScrollView(self._native_activity) vScrollView_layout_params = LinearLayout__LayoutParams( LinearLayout__LayoutParams.MATCH_PARENT, LinearLayout__LayoutParams.MATCH_PARENT, ) vScrollView_layout_params.gravity = Gravity.TOP - vScrollView.setLayoutParams(vScrollView_layout_params) + self.vScrollView.setLayoutParams(vScrollView_layout_params) self.vScrollListener = TogaOnTouchListener() - self.vScrollListener.is_scrolling_enabled = self.interface.vertical - vScrollView.setOnTouchListener(self.vScrollListener) - self.native = vScrollView + self.vScrollView.setOnTouchListener(self.vScrollListener) + self.vScrollView.setOnScrollChangeListener(scroll_listener) + self.hScrollView = HorizontalScrollView(self._native_activity) hScrollView_layout_params = LinearLayout__LayoutParams( LinearLayout__LayoutParams.MATCH_PARENT, @@ -48,58 +59,66 @@ def create(self): ) hScrollView_layout_params.gravity = Gravity.LEFT self.hScrollListener = TogaOnTouchListener() - self.hScrollListener.is_scrolling_enabled = self.interface.horizontal self.hScrollView.setOnTouchListener(self.hScrollListener) - vScrollView.addView(self.hScrollView, hScrollView_layout_params) - if self.interface.content is not None: - self.set_content(self.interface.content) + self.hScrollView.setOnScrollChangeListener(scroll_listener) + self.vScrollView.addView(self.hScrollView, hScrollView_layout_params) + + self.content = None def set_content(self, widget): - widget.viewport = AndroidViewport(self.native, self.interface) - content_view_params = LinearLayout__LayoutParams( - LinearLayout__LayoutParams.MATCH_PARENT, - LinearLayout__LayoutParams.MATCH_PARENT, - ) - if widget.container: - widget.container = None - if self.interface.content: + if self.content: self.hScrollView.removeAllViews() - self.hScrollView.addView(widget.native, content_view_params) - for child in widget.interface.children: - if child._impl.container: + for child in self.content.interface.children: child._impl.container = None - child._impl.container = widget + + self.content = widget + if widget: + widget.viewport = AndroidViewport(self.native, self.interface) + content_view_params = LinearLayout__LayoutParams( + LinearLayout__LayoutParams.MATCH_PARENT, + LinearLayout__LayoutParams.MATCH_PARENT, + ) + if widget.container: + widget.container = None + self.hScrollView.addView(widget.native, content_view_params) + + for child in widget.interface.children: + if child._impl.container: + child._impl.container = None + child._impl.container = widget + + def get_vertical(self): + return self.vScrollListener.is_scrolling_enabled def set_vertical(self, value): self.vScrollListener.is_scrolling_enabled = value + def get_horizontal(self): + return self.hScrollListener.is_scrolling_enabled + def set_horizontal(self, value): self.hScrollListener.is_scrolling_enabled = value - def set_on_scroll(self, on_scroll): - self.interface.factory.not_implemented("ScrollContainer.set_on_scroll()") - def get_vertical_position(self): - self.interface.factory.not_implemented( - "ScrollContainer.get_vertical_position()" - ) - return 0 - - def set_vertical_position(self, vertical_position): - self.interface.factory.not_implemented( - "ScrollContainer.set_vertical_position()" - ) + return self.vScrollView.getScrollY() / self.scale def get_horizontal_position(self): - self.interface.factory.not_implemented( - "ScrollContainer.get_horizontal_position()" - ) - return 0 + return self.hScrollView.getScrollX() / self.scale - def set_horizontal_position(self, horizontal_position): - self.interface.factory.not_implemented( - "ScrollContainer.set_horizontal_position()" - ) + def get_max_horizontal_position(self): + content_width = 0 if self.content is None else self.content.native.getWidth() + return max(0, content_width - self.native.getWidth()) / self.scale + + def get_max_vertical_position(self): + content_height = 0 if self.content is None else self.content.native.getHeight() + return max(0, content_height - self.native.getHeight()) / self.scale + + def set_position(self, horizontal_position, vertical_position): + self.hScrollView.setScrollX(horizontal_position * self.scale) + self.vScrollView.setScrollY(vertical_position * self.scale) + + def set_background_color(self, value): + self.set_background_simple(value) def rehint(self): # Android can crash when rendering some widgets until they have their layout params set. Guard for that case. diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index a84569d52a..fd14c374f5 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -40,6 +40,9 @@ def width(self): def height(self): return self.native.getHeight() + def refreshed(self): + pass + class Window: def __init__(self, interface, title, position, size): diff --git a/android/tests_backend/widgets/scrollcontainer.py b/android/tests_backend/widgets/scrollcontainer.py new file mode 100644 index 0000000000..19fa153d09 --- /dev/null +++ b/android/tests_backend/widgets/scrollcontainer.py @@ -0,0 +1,44 @@ +from android.widget import HorizontalScrollView, ScrollView + +from .base import SimpleProbe + + +class ScrollContainerProbe(SimpleProbe): + native_class = ScrollView + + def __init__(self, widget): + super().__init__(widget) + + assert self.native.getChildCount() == 1 + self.native_inner = self.native.getChildAt(0) + assert isinstance(self.native_inner, HorizontalScrollView) + + @property + def has_content(self): + child_count = self.native_inner.getChildCount() + if child_count == 0: + return False + elif child_count == 1: + return True + else: + raise AssertionError(child_count) + + @property + def _content(self): + return self.native_inner.getChildAt(0) + + @property + def document_height(self): + return self._content.getHeight() / self.scale_factor + + @property + def document_width(self): + return self._content.getWidth() / self.scale_factor + + async def scroll(self): + self.native.setScrollY(200) + self.native_inner.setScrollX(0) + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index a9dabc9910..ac35dab5b6 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -130,7 +130,7 @@ def on_scroll(self, on_scroll): @property def max_horizontal_position(self) -> int | None: - """The maximum horizontal scroll position. + """The maximum horizontal scroll position (read-only). Returns ``None`` if horizontal scrolling is disabled. """ @@ -165,7 +165,7 @@ def horizontal_position(self, horizontal_position): @property def max_vertical_position(self) -> int | None: - """The maximum vertical scroll position. + """The maximum vertical scroll position (read-only). Returns ``None`` if vertical scrolling is disabled. """ @@ -198,6 +198,9 @@ def vertical_position(self, vertical_position): self.position = (self._impl.get_horizontal_position(), vertical_position) + # This combined property is necessary because on some platforms (e.g. iOS), setting + # the horizontal and vertical position separately would cause the horizontal and + # vertical movement to appear as two separate animations. @property def position(self) -> tuple[int | None, int | None]: """The current scroll position. From 8f52e48a4562f6248d4daa2e21537ea2f7256535 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 4 Jul 2023 15:37:31 +0100 Subject: [PATCH 25/40] Android ScrollContainer at 100%, including container rework --- android/src/toga_android/container.py | 70 +++++++++++++++++ android/src/toga_android/widgets/base.py | 51 ++++--------- android/src/toga_android/widgets/box.py | 12 +-- android/src/toga_android/widgets/canvas.py | 64 +++++++--------- .../toga_android/widgets/scrollcontainer.py | 76 ++++++++----------- android/src/toga_android/window.py | 68 ++++------------- android/tests_backend/widgets/base.py | 37 ++++++--- .../tests_backend/widgets/scrollcontainer.py | 41 +++++----- .../tests_backend/widgets/scrollcontainer.py | 3 + gtk/tests_backend/widgets/scrollcontainer.py | 3 + iOS/tests_backend/widgets/scrollcontainer.py | 3 + testbed/tests/widgets/test_scrollcontainer.py | 49 +++++------- 12 files changed, 238 insertions(+), 239 deletions(-) create mode 100644 android/src/toga_android/container.py diff --git a/android/src/toga_android/container.py b/android/src/toga_android/container.py new file mode 100644 index 0000000000..02ab62090f --- /dev/null +++ b/android/src/toga_android/container.py @@ -0,0 +1,70 @@ +from .libs.android.view import ViewGroup__LayoutParams +from .libs.android.widget import RelativeLayout, RelativeLayout__LayoutParams + + +# Common base class of Window, ScrollContainer, and potentially other containers. +class Container: + def clear_content(self): + if self.interface.content: + self.interface.content._impl.viewport = None + + def set_content(self, widget): + self.clear_content() + if widget: + widget.viewport = self.content_viewport + + +class Viewport: + def __init__(self, parent, container): + """ + :param parent: A native widget to display the viewport within. + :param container: An object with a `content` attribute, which, if not None, + will have `refresh()` called on it whenever the viewport size changes. + """ + self.parent = parent + self.container = container + self.width = self.height = 0 + + context = parent.getContext() + self.native = RelativeLayout(context) + self.parent.addView( + self.native, + ViewGroup__LayoutParams( + ViewGroup__LayoutParams.MATCH_PARENT, + ViewGroup__LayoutParams.MATCH_PARENT, + ), + ) + + self.dpi = context.getResources().getDisplayMetrics().densityDpi + # Toga needs to know how the current DPI compares to the platform default, + # which is 160: https://developer.android.com/training/multiscreen/screendensities + self.baseline_dpi = 160 + self.scale = self.dpi / self.baseline_dpi + + def refreshed(self): + pass + + @property + def size(self): + return (self.width, self.height) + + @size.setter + def size(self, size): + if size != (self.width, self.height): + self.width, self.height = size + self.native.setMinimumWidth(self.width) + self.native.setMinimumHeight(self.height) + if self.container.content is not None: + self.container.content.refresh() + + def add_widget(self, widget): + self.native.addView(widget.native) + + def remove_widget(self, widget): + self.native.removeView(widget.native) + + def set_widget_bounds(self, widget, x, y, width, height): + layout_params = RelativeLayout__LayoutParams(width, height) + layout_params.topMargin = y + layout_params.leftMargin = x + self.native.updateViewLayout(widget.native, layout_params) diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 497e2d4cb5..8674cd3e03 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -40,8 +40,7 @@ def __init__(self, interface): super().__init__() self.interface = interface self.interface._impl = self - self._container = None - self.viewport = None + self._viewport = None self.native = None self._native_activity = _get_activity() self.create() @@ -60,35 +59,23 @@ def set_window(self, window): pass @property - def container(self): - return self._container - - @container.setter - def container(self, container): - if self.container: - 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 - # 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) + def viewport(self): + return self._viewport + + @viewport.setter + def viewport(self, viewport): + if self._viewport: + self._viewport.remove_widget(self) + + self._viewport = viewport + if viewport: + viewport.add_widget(self) for child in self.interface.children: - child._impl.container = container + child._impl.viewport = viewport self.rehint() - @property - def scale(self): - viewport = self.viewport if self.viewport else self.container.viewport - return viewport.scale - def get_enabled(self): return self.native.isEnabled() @@ -108,9 +95,7 @@ def set_tab_index(self, tab_index): # APPLICATOR def set_bounds(self, x, y, width, height): - if self.container: - # Ask the container widget to set our bounds. - self.container.set_child_bounds(self, x, y, width, height) + self.viewport.set_widget_bounds(self, x, y, width, height) def set_hidden(self, hidden): if hidden: @@ -165,17 +150,13 @@ def set_color(self, color): # INTERFACE def add_child(self, child): - if self.viewport: - # we are the top level widget - child.container = self - else: - child.container = self.container + child.viewport = self.viewport def insert_child(self, index, child): self.add_child(child) def remove_child(self, child): - child.container = None + child.viewport = None def refresh(self): self.rehint() diff --git a/android/src/toga_android/widgets/box.py b/android/src/toga_android/widgets/box.py index ddb3f15e84..0b7a4f7f79 100644 --- a/android/src/toga_android/widgets/box.py +++ b/android/src/toga_android/widgets/box.py @@ -1,7 +1,7 @@ from travertino.size import at_least from ..libs.activity import MainActivity -from ..libs.android.widget import RelativeLayout, RelativeLayout__LayoutParams +from ..libs.android.widget import RelativeLayout from .base import Widget @@ -9,16 +9,6 @@ class Box(Widget): def create(self): self.native = RelativeLayout(MainActivity.singletonThis) - def set_child_bounds(self, widget, x, y, width, height): - # We assume `widget.native` has already been added to this `RelativeLayout`. - # - # We use `topMargin` and `leftMargin` to perform absolute layout. Not very - # relative, but that's how we do it. - layout_params = RelativeLayout__LayoutParams(width, height) - layout_params.topMargin = y - layout_params.leftMargin = x - self.native.updateViewLayout(widget.native, layout_params) - def set_background_color(self, value): self.set_background_simple(value) diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 81e983bdf6..646c5c4ae8 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -64,33 +64,29 @@ def closed_path(self, x, y, path, *args, **kwargs): path.close() def move_to(self, x, y, path, *args, **kwargs): - path.moveTo( - self.container.viewport.scale * x, self.container.viewport.scale * y - ) + path.moveTo(self.viewport.scale * x, self.viewport.scale * y) def line_to(self, x, y, path, *args, **kwargs): - path.lineTo( - self.container.viewport.scale * x, self.container.viewport.scale * y - ) + path.lineTo(self.viewport.scale * x, self.viewport.scale * y) # Basic shapes def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y, path, *args, **kwargs): path.cubicTo( - cp1x * self.container.viewport.scale, - cp1y * self.container.viewport.scale, - cp2x * self.container.viewport.scale, - cp2y * self.container.viewport.scale, - x * self.container.viewport.scale, - y * self.container.viewport.scale, + cp1x * self.viewport.scale, + cp1y * self.viewport.scale, + cp2x * self.viewport.scale, + cp2y * self.viewport.scale, + x * self.viewport.scale, + y * self.viewport.scale, ) def quadratic_curve_to(self, cpx, cpy, x, y, path, *args, **kwargs): path.quadTo( - cpx * self.container.viewport.scale, - cpy * self.container.viewport.scale, - x * self.container.viewport.scale, - y * self.container.viewport.scale, + cpx * self.viewport.scale, + cpy * self.viewport.scale, + x * self.viewport.scale, + y * self.viewport.scale, ) def arc( @@ -100,10 +96,10 @@ def arc( if anticlockwise: sweep_angle -= math.radians(360) path.arcTo( - self.container.viewport.scale * (x - radius), - self.container.viewport.scale * (y - radius), - self.container.viewport.scale * (x + radius), - self.container.viewport.scale * (y + radius), + self.viewport.scale * (x - radius), + self.viewport.scale * (y - radius), + self.viewport.scale * (x + radius), + self.viewport.scale * (y + radius), math.degrees(startangle), math.degrees(sweep_angle), False, @@ -128,28 +124,28 @@ def ellipse( sweep_angle -= math.radians(360) ellipse_path = Path() ellipse_path.addArc( - self.container.viewport.scale * (x - radiusx), - self.container.viewport.scale * (y - radiusy), - self.container.viewport.scale * (x + radiusx), - self.container.viewport.scale * (y + radiusy), + self.viewport.scale * (x - radiusx), + self.viewport.scale * (y - radiusy), + self.viewport.scale * (x + radiusx), + self.viewport.scale * (y + radiusy), math.degrees(startangle), math.degrees(sweep_angle), ) rotation_matrix = Matrix() rotation_matrix.postRotate( math.degrees(rotation), - self.container.viewport.scale * x, - self.container.viewport.scale * y, + self.viewport.scale * x, + self.viewport.scale * y, ) ellipse_path.transform(rotation_matrix) path.addPath(ellipse_path) def rect(self, x, y, width, height, path, *args, **kwargs): path.addRect( - self.container.viewport.scale * x, - self.container.viewport.scale * y, - self.container.viewport.scale * (x + width), - self.container.viewport.scale * (y + height), + self.viewport.scale * x, + self.viewport.scale * y, + self.viewport.scale * (x + width), + self.viewport.scale * (y + height), Path__Direction.CW, ) @@ -171,7 +167,7 @@ def fill(self, color, fill_rule, preserve, path, canvas, *args, **kwargs): def stroke(self, color, line_width, line_dash, path, canvas, *args, **kwargs): draw_paint = Paint() draw_paint.setAntiAlias(True) - draw_paint.setStrokeWidth(self.container.viewport.scale * line_width) + draw_paint.setStrokeWidth(self.viewport.scale * line_width) draw_paint.setStyle(Paint__Style.STROKE) if color is None: a, r, g, b = 255, 0, 0, 0 @@ -180,7 +176,7 @@ def stroke(self, color, line_width, line_dash, path, canvas, *args, **kwargs): if line_dash is not None: draw_paint.setPathEffect( DashPathEffect( - [(self.container.viewport.scale * float(d)) for d in line_dash], 0.0 + [(self.viewport.scale * float(d)) for d in line_dash], 0.0 ) ) draw_paint.setARGB(a, r, g, b) @@ -197,9 +193,7 @@ def scale(self, sx, sy, canvas, *args, **kwargs): canvas.scale(float(sx), float(sy)) def translate(self, tx, ty, canvas, *args, **kwargs): - canvas.translate( - self.container.viewport.scale * tx, self.container.viewport.scale * ty - ) + canvas.translate(self.viewport.scale * tx, self.viewport.scale * ty) def reset_transform(self, canvas, *args, **kwargs): canvas.restore() diff --git a/android/src/toga_android/widgets/scrollcontainer.py b/android/src/toga_android/widgets/scrollcontainer.py index ccdb545383..aae48a1bfe 100644 --- a/android/src/toga_android/widgets/scrollcontainer.py +++ b/android/src/toga_android/widgets/scrollcontainer.py @@ -1,10 +1,8 @@ from travertino.size import at_least -from toga_android.window import AndroidViewport - +from ..container import Container, Viewport from ..libs.android.view import ( Gravity, - View__MeasureSpec, View__OnScrollChangeListener, View__OnTouchListener, ) @@ -37,7 +35,7 @@ def onScrollChange(self, view, new_x, new_y, old_x, old_y): self.impl.interface.on_scroll(None) -class ScrollContainer(Widget): +class ScrollContainer(Widget, Container): def create(self): scroll_listener = TogaOnScrollListener(self) @@ -63,70 +61,62 @@ def create(self): self.hScrollView.setOnScrollChangeListener(scroll_listener) self.vScrollView.addView(self.hScrollView, hScrollView_layout_params) - self.content = None - - def set_content(self, widget): - if self.content: - self.hScrollView.removeAllViews() - for child in self.content.interface.children: - child._impl.container = None + self.content_viewport = Viewport(self.hScrollView, self.interface) - self.content = widget - if widget: - widget.viewport = AndroidViewport(self.native, self.interface) - content_view_params = LinearLayout__LayoutParams( - LinearLayout__LayoutParams.MATCH_PARENT, - LinearLayout__LayoutParams.MATCH_PARENT, - ) - if widget.container: - widget.container = None - self.hScrollView.addView(widget.native, content_view_params) - - for child in widget.interface.children: - if child._impl.container: - child._impl.container = None - child._impl.container = widget + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) + self.content_viewport.size = (width, height) def get_vertical(self): return self.vScrollListener.is_scrolling_enabled def set_vertical(self, value): + if not value: + self.vScrollView.setScrollY(0) self.vScrollListener.is_scrolling_enabled = value def get_horizontal(self): return self.hScrollListener.is_scrolling_enabled def set_horizontal(self, value): + if not value: + self.hScrollView.setScrollX(0) self.hScrollListener.is_scrolling_enabled = value def get_vertical_position(self): - return self.vScrollView.getScrollY() / self.scale + return int(self.vScrollView.getScrollY() / self.viewport.scale) def get_horizontal_position(self): - return self.hScrollView.getScrollX() / self.scale + return int(self.hScrollView.getScrollX() / self.viewport.scale) def get_max_horizontal_position(self): - content_width = 0 if self.content is None else self.content.native.getWidth() - return max(0, content_width - self.native.getWidth()) / self.scale + if not self.get_horizontal(): + return 0 + else: + return int( + max(0, self.content_viewport.native.getWidth() - self.native.getWidth()) + / self.viewport.scale + ) def get_max_vertical_position(self): - content_height = 0 if self.content is None else self.content.native.getHeight() - return max(0, content_height - self.native.getHeight()) / self.scale + if not self.get_vertical(): + return 0 + else: + return int( + max( + 0, + self.content_viewport.native.getHeight() - self.native.getHeight(), + ) + / self.viewport.scale + ) def set_position(self, horizontal_position, vertical_position): - self.hScrollView.setScrollX(horizontal_position * self.scale) - self.vScrollView.setScrollY(vertical_position * self.scale) + self.hScrollView.setScrollX(int(horizontal_position * self.viewport.scale)) + self.vScrollView.setScrollY(int(vertical_position * self.viewport.scale)) def set_background_color(self, value): self.set_background_simple(value) def rehint(self): - # Android can crash when rendering some widgets until they have their layout params set. Guard for that case. - if not self.native.getLayoutParams(): - return - self.native.measure( - View__MeasureSpec.UNSPECIFIED, - View__MeasureSpec.UNSPECIFIED, - ) - self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) - self.interface.intrinsic.height = at_least(self.native.getMeasuredHeight()) + self.interface.intrinsic.width = at_least(0) + self.interface.intrinsic.height = at_least(0) diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index fd14c374f5..1d951e145c 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -1,50 +1,25 @@ +from .container import Container, Viewport from .libs.android import R__id from .libs.android.view import ViewTreeObserver__OnGlobalLayoutListener -class AndroidViewport(ViewTreeObserver__OnGlobalLayoutListener): - def __init__(self, native, container): - """ - :param native: A native widget whose size will be tracked. - :param container: An object with a ``content`` attribute, which will have - ``refresh()`` called on it whenever the native widget's size changes. - """ +class LayoutListener(ViewTreeObserver__OnGlobalLayoutListener): + def __init__(self, viewport): super().__init__() - self.native = native - self.container = container - self.last_size = (None, None) - native.getViewTreeObserver().addOnGlobalLayoutListener(self) - - self.dpi = native.getContext().getResources().getDisplayMetrics().densityDpi - # Toga needs to know how the current DPI compares to the platform default, - # which is 160: https://developer.android.com/training/multiscreen/screendensities - self.baseline_dpi = 160 - self.scale = float(self.dpi) / self.baseline_dpi + self.viewport = viewport def onGlobalLayout(self): """This listener is run after each native layout pass. If any view's size or position has changed, the new values will be visible here. """ - new_size = (self.width, self.height) - if self.last_size != new_size: - self.last_size = new_size - if self.container.content: - self.container.content.refresh() - - @property - def width(self): - return self.native.getWidth() - - @property - def height(self): - return self.native.getHeight() - - def refreshed(self): - pass + self.viewport.size = ( + self.viewport.parent.getWidth(), + self.viewport.parent.getHeight(), + ) -class Window: +class Window(Container): def __init__(self, interface, title, position, size): super().__init__() self.interface = interface @@ -53,27 +28,12 @@ def __init__(self, interface, title, position, size): def set_app(self, app): self.app = app - self.viewport = AndroidViewport( - self.app.native.findViewById(R__id.content).__global__(), self.interface + viewport_parent = app.native.findViewById(R__id.content) + self.content_viewport = Viewport(viewport_parent, self.interface) + viewport_parent.getViewTreeObserver().addOnGlobalLayoutListener( + LayoutListener(self.content_viewport) ) - def clear_content(self): - if self.interface.content: - for child in self.interface.content.children: - child._impl.container = None - - def set_content(self, widget): - # Set the widget's viewport to be based on the window's content. - widget.viewport = self.viewport - # Set the app's entire contentView to the desired widget. This means that - # calling Window.set_content() on any Window object automatically updates - # the app, meaning that every Window object acts as the MainWindow. - self.app.native.setContentView(widget.native) - - # Attach child widgets to widget as their container. - for child in widget.interface.children: - child._impl.container = widget - def get_title(self): return str(self.app.native.getTitle()) @@ -88,7 +48,7 @@ def set_position(self, position): pass def get_size(self): - return self.viewport.last_size + return self.content_viewport.size def set_size(self, size): # Does nothing on mobile diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 1748e129b2..8f4e39b16d 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -9,8 +9,8 @@ DrawableWrapper, LayerDrawable, ) -from android.os import Build -from android.view import View, ViewTreeObserver +from android.os import Build, SystemClock +from android.view import MotionEvent, View, ViewTreeObserver from toga.colors import TRANSPARENT from toga.style.pack import JUSTIFY, LEFT @@ -52,16 +52,11 @@ def __del__(self): ) def assert_container(self, container): - container_native = container._impl.native - for i in range(container_native.getChildCount()): - child = container_native.getChildAt(i) - if child is self.native: - break - else: - raise AssertionError(f"cannot find {self.native} in {container_native}") + assert self.widget._impl.viewport is container._impl.viewport + assert self.native.getParent() is container._impl.viewport.native def assert_not_contained(self): - assert self.widget._impl.container is None + assert self.widget._impl.viewport is None assert self.native.getParent() is None def assert_alignment(self, expected): @@ -118,7 +113,7 @@ def shrink_on_resize(self): def assert_layout(self, size, position): # Widget is contained - assert self.widget._impl.container is not None + assert self.widget._impl.viewport is not None assert self.native.getParent() is not None # Size and position is as expected. Values must be scaled from DP, and @@ -166,6 +161,26 @@ def background_color(self): async def press(self): self.native.performClick() + def motion_event(self, down_time, action, x, y): + event = MotionEvent.obtain( + down_time, + SystemClock.uptimeMillis(), # eventTime + action, + x, + y, + 0, # metaState + ) + self.native.dispatchTouchEvent(event) + event.recycle() + + async def swipe(self, dx, dy): + down_time = SystemClock.uptimeMillis() + start_x, start_y = (self.width / 2, self.height / 2) + end_x, end_y = (start_x + dx, start_y + dy) + self.motion_event(down_time, MotionEvent.ACTION_DOWN, start_x, start_y) + self.motion_event(down_time, MotionEvent.ACTION_MOVE, end_x, end_y) + self.motion_event(down_time, MotionEvent.ACTION_UP, end_x, end_y) + @property def is_hidden(self): return self.native.getVisibility() == View.INVISIBLE diff --git a/android/tests_backend/widgets/scrollcontainer.py b/android/tests_backend/widgets/scrollcontainer.py index 19fa153d09..1d5a3cc6ef 100644 --- a/android/tests_backend/widgets/scrollcontainer.py +++ b/android/tests_backend/widgets/scrollcontainer.py @@ -1,4 +1,6 @@ -from android.widget import HorizontalScrollView, ScrollView +import asyncio + +from android.widget import HorizontalScrollView, RelativeLayout, ScrollView from .base import SimpleProbe @@ -10,35 +12,34 @@ def __init__(self, widget): super().__init__(widget) assert self.native.getChildCount() == 1 - self.native_inner = self.native.getChildAt(0) - assert isinstance(self.native_inner, HorizontalScrollView) + self.native_horizontal = self.native.getChildAt(0) + assert isinstance(self.native_horizontal, HorizontalScrollView) - @property - def has_content(self): - child_count = self.native_inner.getChildCount() - if child_count == 0: - return False - elif child_count == 1: - return True - else: - raise AssertionError(child_count) + assert self.native_horizontal.getChildCount() == 1 + self.native_content = self.native_horizontal.getChildAt(0) + assert isinstance(self.native_content, RelativeLayout) @property - def _content(self): - return self.native_inner.getChildAt(0) + def has_content(self): + return self.native_content.getChildCount() != 0 @property def document_height(self): - return self._content.getHeight() / self.scale_factor + return self.native_content.getHeight() / self.scale_factor @property def document_width(self): - return self._content.getWidth() / self.scale_factor + return self.native_content.getWidth() / self.scale_factor async def scroll(self): - self.native.setScrollY(200) - self.native_inner.setScrollX(0) + await self.swipe(0, -30) # Swipe up async def wait_for_scroll_completion(self): - # No animation associated with scroll, so this is a no-op - pass + position = self.widget.position + current = None + # Iterate until 2 successive reads of the scroll position, + # 0.05s apart, return the same value + while position != current: + position = current + await asyncio.sleep(0.05) + current = self.widget.position diff --git a/cocoa/tests_backend/widgets/scrollcontainer.py b/cocoa/tests_backend/widgets/scrollcontainer.py index 1535880545..24c117c57f 100644 --- a/cocoa/tests_backend/widgets/scrollcontainer.py +++ b/cocoa/tests_backend/widgets/scrollcontainer.py @@ -25,6 +25,9 @@ def document_width(self): return self.native.documentView.bounds.size.width async def scroll(self): + if not self.native.hasVerticalScroller: + return + self.native.contentView.scrollToPoint(NSMakePoint(0, 600)) self.native.reflectScrolledClipView(self.native.contentView) diff --git a/gtk/tests_backend/widgets/scrollcontainer.py b/gtk/tests_backend/widgets/scrollcontainer.py index f387264ffe..3ad048b805 100644 --- a/gtk/tests_backend/widgets/scrollcontainer.py +++ b/gtk/tests_backend/widgets/scrollcontainer.py @@ -19,6 +19,9 @@ def document_width(self): return self.native.get_hadjustment().get_upper() async def scroll(self): + if self.native.get_policy()[1] == Gtk.PolicyType.NEVER: + return + # Fake a vertical scroll self.native.get_vadjustment().set_value(200) self.native.get_vadjustment().emit("changed") diff --git a/iOS/tests_backend/widgets/scrollcontainer.py b/iOS/tests_backend/widgets/scrollcontainer.py index a8f19a18b0..fa7d3021fd 100644 --- a/iOS/tests_backend/widgets/scrollcontainer.py +++ b/iOS/tests_backend/widgets/scrollcontainer.py @@ -23,6 +23,9 @@ def document_width(self): return self.native.contentSize.width async def scroll(self): + if self.document_height <= self.height: + return + self.native.contentOffset = NSMakePoint(0, 600) async def wait_for_scroll_completion(self): diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 30bc15df9c..8eff05f427 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -268,8 +268,8 @@ async def test_vertical_scroll(widget, probe, on_scroll): on_scroll.reset_mock() -async def test_vertical_scroll_small_content(widget, probe, small_content, on_scroll): - "The widget can be scrolled vertically when the content doesn't need scrolling." +async def test_vertical_scroll_small_content(widget, probe, small_content): + "When the content doesn't need vertical scrolling, attempts to scroll are ignored" widget.content = small_content await probe.redraw("Content has been switched for a small document") @@ -282,22 +282,11 @@ async def test_vertical_scroll_small_content(widget, probe, small_content, on_sc assert widget.horizontal_position == 0 assert widget.vertical_position == 0 - # clear any scroll events caused by setup - on_scroll.reset_mock() - + # This doesn't change the position, so whether it calls on_scroll is undefined. widget.vertical_position = probe.height * 3 await probe.wait_for_scroll_completion() await probe.redraw("Scroll down 3 pages") assert widget.vertical_position == 0 - on_scroll.assert_called_with(widget) - on_scroll.reset_mock() - - widget.vertical_position = 0 - await probe.wait_for_scroll_completion() - await probe.redraw("Scroll back to origin") - assert widget.vertical_position == 0 - on_scroll.assert_called_with(widget) - on_scroll.reset_mock() async def test_horizontal_scroll(widget, probe, content, on_scroll): @@ -346,8 +335,8 @@ async def test_horizontal_scroll(widget, probe, content, on_scroll): on_scroll.reset_mock() -async def test_horizontal_scroll_small_content(widget, probe, small_content, on_scroll): - "The widget can be scrolled horizontally when the content doesn't need scrolling." +async def test_horizontal_scroll_small_content(widget, probe, small_content): + "When the content doesn't need horizontal scrolling, attempts to scroll are ignored" small_content.style.direction = ROW widget.content = small_content await probe.redraw("Content has been switched for a small wide document") @@ -361,22 +350,11 @@ async def test_horizontal_scroll_small_content(widget, probe, small_content, on_ assert widget.horizontal_position == 0 assert widget.vertical_position == 0 - # clear any scroll events caused by setup - on_scroll.reset_mock() - + # This doesn't change the position, so whether it calls on_scroll is undefined. widget.horizontal_position = probe.height * 3 await probe.wait_for_scroll_completion() - await probe.redraw("Scroll down 3 pages") + await probe.redraw("Scroll right 3 pages") assert widget.horizontal_position == 0 - on_scroll.assert_called_with(widget) - on_scroll.reset_mock() - - widget.horizontal_position = 0 - await probe.wait_for_scroll_completion() - await probe.redraw("Scroll back to origin") - assert widget.horizontal_position == 0 - on_scroll.assert_called_with(widget) - on_scroll.reset_mock() async def test_scroll_both(widget, probe, content, on_scroll): @@ -432,10 +410,21 @@ async def test_manual_scroll(widget, probe, content, on_scroll): "The widget can be scrolled manually." await probe.scroll() await probe.wait_for_scroll_completion() - await probe.redraw("Widget has been manually scrolled manually") + await probe.redraw("Widget has been scrolled manually") assert widget.horizontal_position == 0 # We don't care where it's been scrolled to; and there may have been # multiple scroll events. assert widget.vertical_position > 0 on_scroll.assert_called_with(widget) + + # Try again with scrolling disabled. + widget.vertical_position = 0 + widget.vertical = False + on_scroll.reset_mock() + await probe.scroll() + await probe.wait_for_scroll_completion() + await probe.redraw("Attempted scroll with scrolling disabled") + assert widget.horizontal_position == 0 + assert widget.vertical_position == 0 + on_scroll.assert_not_called() From 0dff504e9d400dfe1a5087a59c9f24d8c691ca38 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 4 Jul 2023 16:36:47 +0100 Subject: [PATCH 26/40] Fix CI failures --- dummy/src/toga_dummy/window.py | 2 ++ testbed/tests/widgets/test_scrollcontainer.py | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index 782f6bd199..a251ec3b04 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -50,6 +50,7 @@ def __init__(self, interface, title, position, size): def create_toolbar(self): self._action("create toolbar") + @not_required_on("android") # Android inherits this method from a base class. def clear_content(self): try: widget = self._get_value("content") @@ -58,6 +59,7 @@ def clear_content(self): pass self._action("clear content") + @not_required_on("android") # Android inherits this method from a base class. def set_content(self, widget): self.container.content = widget self._action("set content", widget=widget) diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 8eff05f427..a4675cbb45 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -418,10 +418,14 @@ async def test_manual_scroll(widget, probe, content, on_scroll): assert widget.vertical_position > 0 on_scroll.assert_called_with(widget) - # Try again with scrolling disabled. - widget.vertical_position = 0 + # Disabling scrolling should scroll back to zero widget.vertical = False + await probe.wait_for_scroll_completion() + await probe.redraw("Scroll back to origin, and disable scrolling") + assert widget.vertical_position == 0 on_scroll.reset_mock() + + # With scrolling disabled, `scroll` method should have no effect. await probe.scroll() await probe.wait_for_scroll_completion() await probe.redraw("Attempted scroll with scrolling disabled") From fcd6260eced0e1f7ee03127dd374ec2f5e18c92d Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 4 Jul 2023 18:17:42 +0100 Subject: [PATCH 27/40] Fix DPI and rounding issues --- android/src/toga_android/widgets/base.py | 6 +++ .../toga_android/widgets/scrollcontainer.py | 14 +++---- android/tests_backend/widgets/base.py | 4 +- .../tests_backend/widgets/scrollcontainer.py | 4 +- iOS/src/toga_iOS/widgets/scrollcontainer.py | 4 +- testbed/tests/widgets/test_scrollcontainer.py | 37 ++++++++++++++----- 6 files changed, 46 insertions(+), 23 deletions(-) diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 8674cd3e03..149c874a53 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -76,6 +76,12 @@ def viewport(self, viewport): self.rehint() + def scale_in(self, value): + return int(round(value * self.viewport.scale)) + + def scale_out(self, value): + return int(round(value / self.viewport.scale)) + def get_enabled(self): return self.native.isEnabled() diff --git a/android/src/toga_android/widgets/scrollcontainer.py b/android/src/toga_android/widgets/scrollcontainer.py index aae48a1bfe..488d1defed 100644 --- a/android/src/toga_android/widgets/scrollcontainer.py +++ b/android/src/toga_android/widgets/scrollcontainer.py @@ -84,35 +84,33 @@ def set_horizontal(self, value): self.hScrollListener.is_scrolling_enabled = value def get_vertical_position(self): - return int(self.vScrollView.getScrollY() / self.viewport.scale) + return self.scale_out(self.vScrollView.getScrollY()) def get_horizontal_position(self): - return int(self.hScrollView.getScrollX() / self.viewport.scale) + return self.scale_out(self.hScrollView.getScrollX()) def get_max_horizontal_position(self): if not self.get_horizontal(): return 0 else: - return int( + return self.scale_out( max(0, self.content_viewport.native.getWidth() - self.native.getWidth()) - / self.viewport.scale ) def get_max_vertical_position(self): if not self.get_vertical(): return 0 else: - return int( + return self.scale_out( max( 0, self.content_viewport.native.getHeight() - self.native.getHeight(), ) - / self.viewport.scale ) def set_position(self, horizontal_position, vertical_position): - self.hScrollView.setScrollX(int(horizontal_position * self.viewport.scale)) - self.vScrollView.setScrollY(int(vertical_position * self.viewport.scale)) + self.hScrollView.setScrollX(self.scale_in(horizontal_position)) + self.vScrollView.setScrollY(self.scale_in(vertical_position)) def set_background_color(self, value): self.set_background_simple(value) diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 8f4e39b16d..7ab08c1b63 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -90,12 +90,12 @@ def enabled(self): @property def width(self): # Return the value in DP - return self.native.getWidth() / self.scale_factor + return round(self.native.getWidth() / self.scale_factor) @property def height(self): # Return the value in DP - return self.native.getHeight() / self.scale_factor + return round(self.native.getHeight() / self.scale_factor) def assert_width(self, min_width, max_width): assert ( diff --git a/android/tests_backend/widgets/scrollcontainer.py b/android/tests_backend/widgets/scrollcontainer.py index 1d5a3cc6ef..2ca3ce2ef3 100644 --- a/android/tests_backend/widgets/scrollcontainer.py +++ b/android/tests_backend/widgets/scrollcontainer.py @@ -25,11 +25,11 @@ def has_content(self): @property def document_height(self): - return self.native_content.getHeight() / self.scale_factor + return round(self.native_content.getHeight() / self.scale_factor) @property def document_width(self): - return self.native_content.getWidth() / self.scale_factor + return round(self.native_content.getWidth() / self.scale_factor) async def scroll(self): await self.swipe(0, -30) # Swipe up diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index 8a129344d9..9c39ec48bd 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -107,13 +107,13 @@ def get_horizontal_position(self): def get_max_vertical_position(self): return max( 0, - self.native.contentSize.height - self.native.frame.size.height, + int(self.native.contentSize.height - self.native.frame.size.height), ) def get_max_horizontal_position(self): return max( 0, - self.native.contentSize.width - self.native.frame.size.width, + int(self.native.contentSize.width - self.native.frame.size.width), ) def get_vertical_position(self): diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index a4675cbb45..95bc049d74 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -1,6 +1,7 @@ from unittest.mock import Mock import pytest +from pytest import approx import toga from toga.colors import CORNFLOWERBLUE, REBECCAPURPLE, TRANSPARENT @@ -15,6 +16,10 @@ test_focus_noop, ) +# DPI note: document_width and document_height may be subject to cumulative rounding +# error. This should be fixed by #2020, at which time the `approx` calls with rel=0.01 +# can be removed. The calls with abs=1 will probably have to stay. + @pytest.fixture async def content(): @@ -74,7 +79,7 @@ async def widget(content, on_scroll): async def test_clear_content(widget, probe, small_content): "Widget content can be cleared and reset" assert probe.document_width == probe.width - assert probe.document_height == 6000 + assert probe.document_height == approx(6000, rel=0.01) # TODO: see DPI note widget.content = None await probe.redraw("Widget content has been cleared") @@ -228,10 +233,13 @@ async def test_enable_vertical_scrolling(widget, probe, content, on_scroll): async def test_vertical_scroll(widget, probe, on_scroll): "The widget can be scrolled vertically." assert probe.document_width == probe.width - assert probe.document_height == 6000 + assert probe.document_height == approx(6000, rel=0.01) # TODO: see DPI note assert widget.max_horizontal_position == 0 - assert widget.max_vertical_position == 6000 - probe.height + assert widget.max_vertical_position == approx( + probe.document_height - probe.height, abs=1 + ) + assert isinstance(widget.max_vertical_position, int) assert widget.horizontal_position == 0 assert widget.vertical_position == 0 @@ -294,10 +302,13 @@ async def test_horizontal_scroll(widget, probe, content, on_scroll): content.style.direction = ROW await probe.redraw("Content has been switched for a wide document") - assert probe.document_width == 20000 + assert probe.document_width == approx(20000, rel=0.01) # TODO: see DPI note assert probe.document_height == probe.height - assert widget.max_horizontal_position == 20000 - probe.width + assert widget.max_horizontal_position == approx( + probe.document_width - probe.width, abs=1 + ) + assert isinstance(widget.max_horizontal_position, int) assert widget.max_vertical_position == 0 assert widget.horizontal_position == 0 @@ -373,7 +384,7 @@ async def test_scroll_both(widget, probe, content, on_scroll): ) await probe.redraw("Content has been modified to be wide as well as tall") assert probe.document_width == 2040 - assert probe.document_height == 6060 + assert probe.document_height == approx(6060, rel=0.01) # TODO: see DPI note assert widget.horizontal_position == 0 assert widget.vertical_position == 0 @@ -385,7 +396,9 @@ async def test_scroll_both(widget, probe, content, on_scroll): await probe.wait_for_scroll_completion() await probe.redraw("Scroll to mid document") assert widget.horizontal_position == 1000 + assert isinstance(widget.horizontal_position, int) assert widget.vertical_position == 2000 + assert isinstance(widget.vertical_position, int) on_scroll.assert_called_with(widget) on_scroll.reset_mock() @@ -393,15 +406,21 @@ async def test_scroll_both(widget, probe, content, on_scroll): await probe.wait_for_scroll_completion() await probe.redraw("Scroll to bottom left") assert widget.horizontal_position == 0 - assert widget.vertical_position == 6060 - probe.height + assert widget.vertical_position == approx( + probe.document_height - probe.height, abs=1 + ) on_scroll.assert_called_with(widget) on_scroll.reset_mock() widget.position = 10000, 20000 await probe.wait_for_scroll_completion() await probe.redraw("Scroll to bottom right") - assert widget.horizontal_position == 2040 - probe.width - assert widget.vertical_position == 6060 - probe.height + assert widget.horizontal_position == approx( + probe.document_width - probe.width, abs=1 + ) + assert widget.vertical_position == approx( + probe.document_height - probe.height, abs=1 + ) on_scroll.assert_called_with(widget) on_scroll.reset_mock() From e6c2cf2fa1675351140d7e0305dd7259946e9b44 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 5 Jul 2023 09:53:02 +0100 Subject: [PATCH 28/40] Update widget support table --- docs/reference/api/containers/scrollcontainer.rst | 2 +- docs/reference/data/widgets_by_platform.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/api/containers/scrollcontainer.rst b/docs/reference/api/containers/scrollcontainer.rst index c0bb801f8d..e8e7b2e0f7 100644 --- a/docs/reference/api/containers/scrollcontainer.rst +++ b/docs/reference/api/containers/scrollcontainer.rst @@ -1,7 +1,7 @@ Scroll Container ================ -A container that can display a layout larger that the area of the container, with +A container that can display a layout larger than the area of the container, with overflow controlled by scroll bars. .. figure:: /reference/images/ScrollContainer.png diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index e6e0b0c995..ef89fb7fef 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -24,7 +24,7 @@ Tree,General Widget,:class:`~toga.Tree`,Tree of data,|b|,|b|,|b|,,, WebView,General Widget,:class:`~toga.WebView`,A panel for displaying HTML,|y|,|y|,|y|,|y|,|y|, Widget,General Widget,:class:`~toga.Widget`,The base widget,|y|,|y|,|y|,|y|,|y|,|b| Box,Layout Widget,:class:`~toga.Box`,Container for components,|y|,|y|,|y|,|y|,|y|,|b| -ScrollContainer,Layout Widget,:class:`~toga.ScrollContainer`,"A container that can display a layout larger that the area of the container, with overflow controlled by scroll bars.",|b|,|b|,|b|,|b|,|b|, +ScrollContainer,Layout Widget,:class:`~toga.ScrollContainer`,A container that can display a layout larger than the area of the container,|y|,|y|,|y|,|y|,|y|, SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,Split Container,|b|,|b|,|b|,,, OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,Option Container,|b|,|b|,|b|,,, App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|, From 814a7a25ad92948fe349076ed3455342d1fb3185 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 5 Jul 2023 09:54:30 +0100 Subject: [PATCH 29/40] More rounding fixes --- gtk/src/toga_gtk/widgets/scrollcontainer.py | 16 ++++++++++------ testbed/tests/widgets/test_scrollcontainer.py | 12 ++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/gtk/src/toga_gtk/widgets/scrollcontainer.py b/gtk/src/toga_gtk/widgets/scrollcontainer.py index 0a4a44fb92..2d62723cb2 100644 --- a/gtk/src/toga_gtk/widgets/scrollcontainer.py +++ b/gtk/src/toga_gtk/widgets/scrollcontainer.py @@ -77,12 +77,14 @@ def get_max_vertical_position(self): return max( 0, - self.native.get_vadjustment().get_upper() - - self.native.get_allocation().height, + int( + self.native.get_vadjustment().get_upper() + - self.native.get_allocation().height + ), ) def get_vertical_position(self): - return self.native.get_vadjustment().get_value() + return int(self.native.get_vadjustment().get_value()) def get_max_horizontal_position(self): if not self.get_horizontal(): @@ -90,12 +92,14 @@ def get_max_horizontal_position(self): return max( 0, - self.native.get_hadjustment().get_upper() - - self.native.get_allocation().width, + int( + self.native.get_hadjustment().get_upper() + - self.native.get_allocation().width + ), ) def get_horizontal_position(self): - return self.native.get_hadjustment().get_value() + return int(self.native.get_hadjustment().get_value()) def set_position(self, horizontal_position, vertical_position): self.native.get_hadjustment().set_value(horizontal_position) diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 95bc049d74..861cf4ddab 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -17,8 +17,8 @@ ) # DPI note: document_width and document_height may be subject to cumulative rounding -# error. This should be fixed by #2020, at which time the `approx` calls with rel=0.01 -# can be removed. The calls with abs=1 will probably have to stay. +# error. This should be fixed by #2020, at which time the `approx` calls with abs=100 +# can be removed, or at least reduced to abs=1. @pytest.fixture @@ -79,7 +79,7 @@ async def widget(content, on_scroll): async def test_clear_content(widget, probe, small_content): "Widget content can be cleared and reset" assert probe.document_width == probe.width - assert probe.document_height == approx(6000, rel=0.01) # TODO: see DPI note + assert probe.document_height == approx(6000, abs=100) # TODO: see DPI note widget.content = None await probe.redraw("Widget content has been cleared") @@ -233,7 +233,7 @@ async def test_enable_vertical_scrolling(widget, probe, content, on_scroll): async def test_vertical_scroll(widget, probe, on_scroll): "The widget can be scrolled vertically." assert probe.document_width == probe.width - assert probe.document_height == approx(6000, rel=0.01) # TODO: see DPI note + assert probe.document_height == approx(6000, abs=100) # TODO: see DPI note assert widget.max_horizontal_position == 0 assert widget.max_vertical_position == approx( @@ -302,7 +302,7 @@ async def test_horizontal_scroll(widget, probe, content, on_scroll): content.style.direction = ROW await probe.redraw("Content has been switched for a wide document") - assert probe.document_width == approx(20000, rel=0.01) # TODO: see DPI note + assert probe.document_width == approx(20000, abs=100) # TODO: see DPI note assert probe.document_height == probe.height assert widget.max_horizontal_position == approx( @@ -384,7 +384,7 @@ async def test_scroll_both(widget, probe, content, on_scroll): ) await probe.redraw("Content has been modified to be wide as well as tall") assert probe.document_width == 2040 - assert probe.document_height == approx(6060, rel=0.01) # TODO: see DPI note + assert probe.document_height == approx(6060, abs=100) # TODO: see DPI note assert widget.horizontal_position == 0 assert widget.vertical_position == 0 From 1eeb43934d8662d6e6d22a012a10853548887334 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 5 Jul 2023 20:58:04 +0100 Subject: [PATCH 30/40] Further Android container refactoring, fixing padding on ScrollContainer content root --- android/src/toga_android/container.py | 92 ++++++++----------- android/src/toga_android/widgets/base.py | 36 ++++---- android/src/toga_android/widgets/canvas.py | 58 ++++++------ .../toga_android/widgets/scrollcontainer.py | 15 ++- android/src/toga_android/window.py | 22 ++--- android/tests_backend/widgets/base.py | 8 +- .../scrollcontainer/scrollcontainer/app.py | 80 +++++++++------- testbed/tests/testbed.py | 17 ++-- testbed/tests/widgets/test_scrollcontainer.py | 24 +++++ 9 files changed, 186 insertions(+), 166 deletions(-) diff --git a/android/src/toga_android/container.py b/android/src/toga_android/container.py index 02ab62090f..3f517c5778 100644 --- a/android/src/toga_android/container.py +++ b/android/src/toga_android/container.py @@ -1,39 +1,13 @@ -from .libs.android.view import ViewGroup__LayoutParams from .libs.android.widget import RelativeLayout, RelativeLayout__LayoutParams -# Common base class of Window, ScrollContainer, and potentially other containers. class Container: - def clear_content(self): - if self.interface.content: - self.interface.content._impl.viewport = None - - def set_content(self, widget): - self.clear_content() - if widget: - widget.viewport = self.content_viewport - - -class Viewport: - def __init__(self, parent, container): - """ - :param parent: A native widget to display the viewport within. - :param container: An object with a `content` attribute, which, if not None, - will have `refresh()` called on it whenever the viewport size changes. - """ - self.parent = parent - self.container = container + def init_container(self, native_parent): self.width = self.height = 0 - context = parent.getContext() - self.native = RelativeLayout(context) - self.parent.addView( - self.native, - ViewGroup__LayoutParams( - ViewGroup__LayoutParams.MATCH_PARENT, - ViewGroup__LayoutParams.MATCH_PARENT, - ), - ) + context = native_parent.getContext() + self.native_content = RelativeLayout(context) + native_parent.addView(self.native_content) self.dpi = context.getResources().getDisplayMetrics().densityDpi # Toga needs to know how the current DPI compares to the platform default, @@ -41,30 +15,40 @@ def __init__(self, parent, container): self.baseline_dpi = 160 self.scale = self.dpi / self.baseline_dpi - def refreshed(self): - pass - - @property - def size(self): - return (self.width, self.height) - - @size.setter - def size(self, size): - if size != (self.width, self.height): - self.width, self.height = size - self.native.setMinimumWidth(self.width) - self.native.setMinimumHeight(self.height) - if self.container.content is not None: - self.container.content.refresh() + def set_content(self, widget): + self.clear_content() + if widget: + widget.container = self - def add_widget(self, widget): - self.native.addView(widget.native) + def clear_content(self): + if self.interface.content: + self.interface.content._impl.container = None - def remove_widget(self, widget): - self.native.removeView(widget.native) + def resize_content(self, width, height): + if (self.width, self.height) != (width, height): + self.width, self.height = (width, height) + if self.interface.content: + self.interface.content.refresh() - def set_widget_bounds(self, widget, x, y, width, height): - layout_params = RelativeLayout__LayoutParams(width, height) - layout_params.topMargin = y - layout_params.leftMargin = x - self.native.updateViewLayout(widget.native, layout_params) + def refreshed(self): + # We must use the correct LayoutParams class, but we don't know what that class + # is, so reuse the existing object. Calling the constructor of type(lp) is also + # an option, but would probably be less safe because a subclass might change the + # meaning of the (int, int) constructor. + lp = self.native_content.getLayoutParams() + layout = self.interface.content.layout + lp.width = max(self.width, layout.width) + lp.height = max(self.height, layout.height) + self.native_content.setLayoutParams(lp) + + def add_content(self, widget): + self.native_content.addView(widget.native) + + def remove_content(self, widget): + self.native_content.removeView(widget.native) + + def set_content_bounds(self, widget, x, y, width, height): + lp = RelativeLayout__LayoutParams(width, height) + lp.topMargin = y + lp.leftMargin = x + widget.native.setLayoutParams(lp) diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 149c874a53..208fc5f13c 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -40,7 +40,7 @@ def __init__(self, interface): super().__init__() self.interface = interface self.interface._impl = self - self._viewport = None + self._container = None self.native = None self._native_activity = _get_activity() self.create() @@ -59,28 +59,32 @@ def set_window(self, window): pass @property - def viewport(self): - return self._viewport + def container(self): + return self._container - @viewport.setter - def viewport(self, viewport): - if self._viewport: - self._viewport.remove_widget(self) + @container.setter + def container(self, container): + if self._container: + self._container.remove_content(self) - self._viewport = viewport - if viewport: - viewport.add_widget(self) + self._container = container + if container: + container.add_content(self) for child in self.interface.children: - child._impl.viewport = viewport + child._impl.container = container self.rehint() + @property + def viewport(self): + return self._container + def scale_in(self, value): - return int(round(value * self.viewport.scale)) + return int(round(value * self.container.scale)) def scale_out(self, value): - return int(round(value / self.viewport.scale)) + return int(round(value / self.container.scale)) def get_enabled(self): return self.native.isEnabled() @@ -101,7 +105,7 @@ def set_tab_index(self, tab_index): # APPLICATOR def set_bounds(self, x, y, width, height): - self.viewport.set_widget_bounds(self, x, y, width, height) + self.container.set_content_bounds(self, x, y, width, height) def set_hidden(self, hidden): if hidden: @@ -156,13 +160,13 @@ def set_color(self, color): # INTERFACE def add_child(self, child): - child.viewport = self.viewport + child.container = self.container def insert_child(self, index, child): self.add_child(child) def remove_child(self, child): - child.viewport = None + child.container = None def refresh(self): self.rehint() diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 646c5c4ae8..f1f5f16d6c 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -64,29 +64,29 @@ def closed_path(self, x, y, path, *args, **kwargs): path.close() def move_to(self, x, y, path, *args, **kwargs): - path.moveTo(self.viewport.scale * x, self.viewport.scale * y) + path.moveTo(self.container.scale * x, self.container.scale * y) def line_to(self, x, y, path, *args, **kwargs): - path.lineTo(self.viewport.scale * x, self.viewport.scale * y) + path.lineTo(self.container.scale * x, self.container.scale * y) # Basic shapes def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y, path, *args, **kwargs): path.cubicTo( - cp1x * self.viewport.scale, - cp1y * self.viewport.scale, - cp2x * self.viewport.scale, - cp2y * self.viewport.scale, - x * self.viewport.scale, - y * self.viewport.scale, + cp1x * self.container.scale, + cp1y * self.container.scale, + cp2x * self.container.scale, + cp2y * self.container.scale, + x * self.container.scale, + y * self.container.scale, ) def quadratic_curve_to(self, cpx, cpy, x, y, path, *args, **kwargs): path.quadTo( - cpx * self.viewport.scale, - cpy * self.viewport.scale, - x * self.viewport.scale, - y * self.viewport.scale, + cpx * self.container.scale, + cpy * self.container.scale, + x * self.container.scale, + y * self.container.scale, ) def arc( @@ -96,10 +96,10 @@ def arc( if anticlockwise: sweep_angle -= math.radians(360) path.arcTo( - self.viewport.scale * (x - radius), - self.viewport.scale * (y - radius), - self.viewport.scale * (x + radius), - self.viewport.scale * (y + radius), + self.container.scale * (x - radius), + self.container.scale * (y - radius), + self.container.scale * (x + radius), + self.container.scale * (y + radius), math.degrees(startangle), math.degrees(sweep_angle), False, @@ -124,28 +124,28 @@ def ellipse( sweep_angle -= math.radians(360) ellipse_path = Path() ellipse_path.addArc( - self.viewport.scale * (x - radiusx), - self.viewport.scale * (y - radiusy), - self.viewport.scale * (x + radiusx), - self.viewport.scale * (y + radiusy), + self.container.scale * (x - radiusx), + self.container.scale * (y - radiusy), + self.container.scale * (x + radiusx), + self.container.scale * (y + radiusy), math.degrees(startangle), math.degrees(sweep_angle), ) rotation_matrix = Matrix() rotation_matrix.postRotate( math.degrees(rotation), - self.viewport.scale * x, - self.viewport.scale * y, + self.container.scale * x, + self.container.scale * y, ) ellipse_path.transform(rotation_matrix) path.addPath(ellipse_path) def rect(self, x, y, width, height, path, *args, **kwargs): path.addRect( - self.viewport.scale * x, - self.viewport.scale * y, - self.viewport.scale * (x + width), - self.viewport.scale * (y + height), + self.container.scale * x, + self.container.scale * y, + self.container.scale * (x + width), + self.container.scale * (y + height), Path__Direction.CW, ) @@ -167,7 +167,7 @@ def fill(self, color, fill_rule, preserve, path, canvas, *args, **kwargs): def stroke(self, color, line_width, line_dash, path, canvas, *args, **kwargs): draw_paint = Paint() draw_paint.setAntiAlias(True) - draw_paint.setStrokeWidth(self.viewport.scale * line_width) + draw_paint.setStrokeWidth(self.container.scale * line_width) draw_paint.setStyle(Paint__Style.STROKE) if color is None: a, r, g, b = 255, 0, 0, 0 @@ -176,7 +176,7 @@ def stroke(self, color, line_width, line_dash, path, canvas, *args, **kwargs): if line_dash is not None: draw_paint.setPathEffect( DashPathEffect( - [(self.viewport.scale * float(d)) for d in line_dash], 0.0 + [(self.container.scale * float(d)) for d in line_dash], 0.0 ) ) draw_paint.setARGB(a, r, g, b) @@ -193,7 +193,7 @@ def scale(self, sx, sy, canvas, *args, **kwargs): canvas.scale(float(sx), float(sy)) def translate(self, tx, ty, canvas, *args, **kwargs): - canvas.translate(self.viewport.scale * tx, self.viewport.scale * ty) + canvas.translate(self.container.scale * tx, self.container.scale * ty) def reset_transform(self, canvas, *args, **kwargs): canvas.restore() diff --git a/android/src/toga_android/widgets/scrollcontainer.py b/android/src/toga_android/widgets/scrollcontainer.py index 488d1defed..f29873d147 100644 --- a/android/src/toga_android/widgets/scrollcontainer.py +++ b/android/src/toga_android/widgets/scrollcontainer.py @@ -1,6 +1,6 @@ from travertino.size import at_least -from ..container import Container, Viewport +from ..container import Container from ..libs.android.view import ( Gravity, View__OnScrollChangeListener, @@ -61,11 +61,13 @@ def create(self): self.hScrollView.setOnScrollChangeListener(scroll_listener) self.vScrollView.addView(self.hScrollView, hScrollView_layout_params) - self.content_viewport = Viewport(self.hScrollView, self.interface) + self.init_container(self.hScrollView) def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) - self.content_viewport.size = (width, height) + self.resize_content(width, height) + if self.interface.content: + self.interface.content.refresh() def get_vertical(self): return self.vScrollListener.is_scrolling_enabled @@ -94,7 +96,7 @@ def get_max_horizontal_position(self): return 0 else: return self.scale_out( - max(0, self.content_viewport.native.getWidth() - self.native.getWidth()) + max(0, self.native_content.getWidth() - self.native.getWidth()) ) def get_max_vertical_position(self): @@ -102,10 +104,7 @@ def get_max_vertical_position(self): return 0 else: return self.scale_out( - max( - 0, - self.content_viewport.native.getHeight() - self.native.getHeight(), - ) + max(0, self.native_content.getHeight() - self.native.getHeight()) ) def set_position(self, horizontal_position, vertical_position): diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index 1d951e145c..4e1a3b4046 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -1,22 +1,20 @@ -from .container import Container, Viewport +from .container import Container from .libs.android import R__id from .libs.android.view import ViewTreeObserver__OnGlobalLayoutListener class LayoutListener(ViewTreeObserver__OnGlobalLayoutListener): - def __init__(self, viewport): + def __init__(self, window): super().__init__() - self.viewport = viewport + self.window = window def onGlobalLayout(self): """This listener is run after each native layout pass. If any view's size or position has changed, the new values will be visible here. """ - self.viewport.size = ( - self.viewport.parent.getWidth(), - self.viewport.parent.getHeight(), - ) + native_parent = self.window.native_content.getParent() + self.window.resize_content(native_parent.getWidth(), native_parent.getHeight()) class Window(Container): @@ -28,10 +26,10 @@ def __init__(self, interface, title, position, size): def set_app(self, app): self.app = app - viewport_parent = app.native.findViewById(R__id.content) - self.content_viewport = Viewport(viewport_parent, self.interface) - viewport_parent.getViewTreeObserver().addOnGlobalLayoutListener( - LayoutListener(self.content_viewport) + native_parent = app.native.findViewById(R__id.content) + self.init_container(native_parent) + native_parent.getViewTreeObserver().addOnGlobalLayoutListener( + LayoutListener(self) ) def get_title(self): @@ -48,7 +46,7 @@ def set_position(self, position): pass def get_size(self): - return self.content_viewport.size + return (self.width, self.height) def set_size(self, size): # Does nothing on mobile diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 7ab08c1b63..f6316c3056 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -52,11 +52,11 @@ def __del__(self): ) def assert_container(self, container): - assert self.widget._impl.viewport is container._impl.viewport - assert self.native.getParent() is container._impl.viewport.native + assert self.widget._impl.container is container._impl.container + assert self.native.getParent() is container._impl.container.native_content def assert_not_contained(self): - assert self.widget._impl.viewport is None + assert self.widget._impl.container is None assert self.native.getParent() is None def assert_alignment(self, expected): @@ -113,7 +113,7 @@ def shrink_on_resize(self): def assert_layout(self, size, position): # Widget is contained - assert self.widget._impl.viewport is not None + 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 diff --git a/examples/scrollcontainer/scrollcontainer/app.py b/examples/scrollcontainer/scrollcontainer/app.py index b30408585a..b26aa741f1 100644 --- a/examples/scrollcontainer/scrollcontainer/app.py +++ b/examples/scrollcontainer/scrollcontainer/app.py @@ -4,53 +4,57 @@ class Item(toga.Box): - def __init__(self, text): - super().__init__(style=Pack(direction=COLUMN)) + def __init__(self, width, text): + super().__init__(style=Pack(direction=ROW, padding=10, background_color="lime")) - row = toga.Box(style=Pack(direction=ROW)) - for x in range(10): - label = toga.Label(text + ", " + str(x), style=Pack(padding_right=10)) - row.add(label) - self.add(row) + for x in range(width): + label = toga.Label( + text + "," + str(x), + style=Pack(padding_right=10, background_color="cyan"), + ) + self.add(label) class ScrollContainerApp(toga.App): TOGGLE_CHUNK = 10 - vscrolling = True - hscrolling = False - scroller = None - def startup(self): main_box = toga.Box(style=Pack(direction=COLUMN)) self.vswitch = toga.Switch( - "Vertical", - value=self.vscrolling, + "Vert", + value=True, on_change=self.handle_vscrolling, ) self.hswitch = toga.Switch( - "Horizontal", - value=self.hscrolling, + "Horiz", + value=False, on_change=self.handle_hscrolling, ) + self.big_switch = toga.Switch( + "Big", + value=True, + on_change=lambda widget: self.update_content(), + ) main_box.add( - toga.Box(style=Pack(direction=ROW), children=[self.vswitch, self.hswitch]) + toga.Box( + style=Pack(direction=ROW), + children=[self.vswitch, self.hswitch, self.big_switch], + ) ) - box = toga.Box(style=Pack(direction=COLUMN, padding=10)) + self.inner_box = toga.Box( + style=Pack(direction=COLUMN, padding=10, background_color="yellow") + ) self.scroller = toga.ScrollContainer( - horizontal=self.hscrolling, - vertical=self.vscrolling, + horizontal=self.hswitch.value, + vertical=self.vswitch.value, on_scroll=self.on_scroll, - style=Pack(flex=1), + style=Pack(flex=1, padding=10, background_color="pink"), ) + self.update_content() - for x in range(100): - label_text = f"Label {x}" - box.add(Item(label_text)) - - self.scroller.content = box + self.scroller.content = self.inner_box main_box.add(self.scroller) self.main_window = toga.MainWindow(self.name, size=(400, 700)) @@ -89,23 +93,29 @@ def startup(self): ) def handle_hscrolling(self, widget): - self.hscrolling = widget.value - self.scroller.horizontal = self.hscrolling + self.scroller.horizontal = self.hswitch.value def handle_vscrolling(self, widget): - self.vscrolling = widget.value - self.scroller.vertical = self.vscrolling + self.scroller.vertical = self.vswitch.value + + def update_content(self): + self.inner_box.clear() + + width, height = (10, 50) if self.big_switch.value else (2, 2) + for x in range(height): + label_text = f"Label {x}" + self.inner_box.add(Item(width, label_text)) def on_scroll(self, scroller): - self.hswitch.text = "Horizontal " + ( + self.hswitch.text = "Horiz " + ( f"({scroller.horizontal_position} / {scroller.max_horizontal_position})" ) - self.vswitch.text = "Vertical " + ( + self.vswitch.text = "Vert " + ( f"({scroller.vertical_position} / {scroller.max_vertical_position})" ) def toggle_up(self, widget): - if not self.vscrolling: + if not self.vswitch.value: return try: self.scroller.vertical_position -= self.TOGGLE_CHUNK @@ -113,7 +123,7 @@ def toggle_up(self, widget): pass def toggle_down(self, widget): - if not self.vscrolling: + if not self.vswitch.value: return try: self.scroller.vertical_position += self.TOGGLE_CHUNK @@ -121,7 +131,7 @@ def toggle_down(self, widget): pass def toggle_left(self, widget): - if not self.hscrolling: + if not self.hswitch.value: return try: self.scroller.horizontal_position -= self.TOGGLE_CHUNK @@ -129,7 +139,7 @@ def toggle_left(self, widget): pass def toggle_right(self, widget): - if not self.hscrolling: + if not self.hswitch.value: return try: self.scroller.horizontal_position += self.TOGGLE_CHUNK diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index e610d92d69..fd8da6b187 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -79,14 +79,6 @@ def run_tests(app, cov, args, report_coverage, run_slow): if __name__ == "__main__": - # Prevent the log being cluttered with "avc: denied" messages - # (https://github.com/beeware/toga/issues/1962). - def get_terminal_size(*args, **kwargs): - error = errno.ENOTTY - raise OSError(error, os.strerror(error)) - - os.get_terminal_size = get_terminal_size - # Determine the toga backend. This replicates the behavior in toga/platform.py; # we can't use that module directly because we need to capture all the import # side effects as part of the coverage data. @@ -104,6 +96,15 @@ def get_terminal_size(*args, **kwargs): "win32": "toga_winforms", }.get(sys.platform) + if toga_backend == "toga_android": + # Prevent the log being cluttered with "avc: denied" messages + # (https://github.com/beeware/toga/issues/1962). + def get_terminal_size(*args, **kwargs): + error = errno.ENOTTY + raise OSError(error, os.strerror(error)) + + os.get_terminal_size = get_terminal_size + # Start coverage tracking. # This needs to happen in the main thread, before the app has been created cov = coverage.Coverage( diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 861cf4ddab..84cc6db1c0 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -96,6 +96,28 @@ async def test_clear_content(widget, probe, small_content): assert probe.document_height == probe.height +async def test_padding(widget, probe, content): + "Padding works correctly on the root widget" + original_width = probe.width + original_height = probe.height + original_document_width = probe.document_width + original_document_height = probe.document_height + + content.style.padding = 21 + await probe.redraw("Add padding") + assert probe.width == original_width + assert probe.height == original_height + assert probe.document_width == original_document_width + assert probe.document_height == original_document_height + 42 + + content.style.padding = 0 + await probe.redraw("Remove padding") + assert probe.width == original_width + assert probe.height == original_height + assert probe.document_width == original_document_width + assert probe.document_height == original_document_height + + async def test_enable_horizontal_scrolling(widget, probe, content, on_scroll): "Horizontal scrolling can be disabled" # Add some wide content @@ -233,6 +255,7 @@ async def test_enable_vertical_scrolling(widget, probe, content, on_scroll): async def test_vertical_scroll(widget, probe, on_scroll): "The widget can be scrolled vertically." assert probe.document_width == probe.width + assert probe.document_height > probe.height assert probe.document_height == approx(6000, abs=100) # TODO: see DPI note assert widget.max_horizontal_position == 0 @@ -302,6 +325,7 @@ async def test_horizontal_scroll(widget, probe, content, on_scroll): content.style.direction = ROW await probe.redraw("Content has been switched for a wide document") + assert probe.document_width > probe.width assert probe.document_width == approx(20000, abs=100) # TODO: see DPI note assert probe.document_height == probe.height From ee52a10a0319ffa455a6d45bd76f94f060c055cf Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 6 Jul 2023 18:45:35 +0100 Subject: [PATCH 31/40] Refactor Winforms container/viewport mechanism --- winforms/src/toga_winforms/app.py | 4 +- winforms/src/toga_winforms/container.py | 48 ++++++ winforms/src/toga_winforms/widgets/base.py | 51 ++---- winforms/src/toga_winforms/widgets/box.py | 24 +-- winforms/src/toga_winforms/widgets/canvas.py | 2 +- .../toga_winforms/widgets/optioncontainer.py | 4 +- .../toga_winforms/widgets/scrollcontainer.py | 12 +- .../toga_winforms/widgets/splitcontainer.py | 6 +- winforms/src/toga_winforms/window.py | 151 ++++++------------ winforms/tests_backend/widgets/base.py | 17 +- 10 files changed, 133 insertions(+), 186 deletions(-) create mode 100644 winforms/src/toga_winforms/container.py diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 32f0dc31c3..db3ae3dc58 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -163,9 +163,11 @@ def create_menus(self): self._menu_items[item] = cmd submenu.DropDownItems.Add(item) + # The menu bar doesn't need to be positioned, because its `Dock` property + # defaults to `Top`. self.interface.main_window._impl.native.Controls.Add(menubar) self.interface.main_window._impl.native.MainMenuStrip = menubar - self.interface.main_window.content.refresh() + self.interface.main_window._impl.resize_content() def _submenu(self, group, menubar): try: diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py new file mode 100644 index 0000000000..2bbcac83e2 --- /dev/null +++ b/winforms/src/toga_winforms/container.py @@ -0,0 +1,48 @@ +from toga_winforms.libs import Size, WinForms + + +class BaseContainer: + def __init__(self, native_parent): + self.width = self.height = 0 + self.baseline_dpi = 96 + self.dpi = native_parent.CreateGraphics().DpiX + + +class MinimumContainer(BaseContainer): + def refreshed(self): + pass + + +class Container(BaseContainer): + def __init__(self, native_parent): + super().__init__(native_parent) + self.native_content = WinForms.Panel() + native_parent.Controls.Add(self.native_content) + + def set_content(self, widget): + self.clear_content() + if widget: + widget.container = self + + def clear_content(self): + if self.interface.content: + self.interface.content._impl.container = None + + def resize_content(self, width, height): + if (self.width, self.height) != (width, height): + self.width, self.height = (width, height) + if self.interface.content: + self.interface.content.refresh() + + def refreshed(self): + layout = self.interface.content.layout + self.native_content.Size = Size( + max(self.width, layout.width), + max(self.height, layout.height), + ) + + def add_content(self, widget): + self.native_content.Controls.Add(widget.native) + + def remove_content(self, widget): + self.native_content.Controls.Remove(widget.native) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 62de014789..30e23b67d3 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -17,7 +17,6 @@ def __init__(self, interface): self._container = None self.native = None - self.viewport = None self.create() self.interface.style.reapply() @@ -39,29 +38,22 @@ def container(self): @container.setter def container(self, container): - if self.container: - 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 - self._container.native.Controls.Add(self.native) - self.native.BringToFront() - + # To obtain the correct Z-order, add children before self. for child in self.interface.children: child._impl.container = container + if self._container: + self._container.remove_content(self) + + self._container = container + if container: + container.add_content(self) + self.rehint() @property def viewport(self): - return self._viewport - - @viewport.setter - def viewport(self, viewport): - self._viewport = viewport + return self._container def get_tab_index(self): return self.native.TabIndex @@ -80,20 +72,9 @@ def focus(self): # APPLICATOR - @property - def vertical_shift(self): - return 0 - def set_bounds(self, x, y, width, height): - # 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.Location = Point(x, y) def set_alignment(self, alignment): # By default, alignment can't be changed @@ -125,18 +106,10 @@ def set_background_color(self, color): # INTERFACE def add_child(self, child): - if self.viewport: - # we are the top level container - child.container = self - else: - child.container = self.container + child.container = self.container def insert_child(self, index, child): - if self.viewport: - # we are the the top level container - child.container = self - else: - child.container = self.container + self.add_child(child) def remove_child(self, child): child.container = None diff --git a/winforms/src/toga_winforms/widgets/box.py b/winforms/src/toga_winforms/widgets/box.py index d4c0ae0a78..8ff97db784 100644 --- a/winforms/src/toga_winforms/widgets/box.py +++ b/winforms/src/toga_winforms/widgets/box.py @@ -2,7 +2,7 @@ from toga.colors import TRANSPARENT from toga_winforms.colors import native_color -from toga_winforms.libs import Point, Size, WinForms +from toga_winforms.libs import WinForms from .base import Widget @@ -12,28 +12,6 @@ def create(self): self.native = WinForms.Panel() self.native.interface = self.interface - def set_bounds(self, x, y, width, height): - vertical_shift = 0 - try: - # If the box is the outer widget, we need to shift it to the frame vertical_shift - vertical_shift = ( - self.frame.vertical_shift - self.interface.style.padding_top - ) - # The outermost widget assumes the size of the viewport - width = self.viewport.width - height = self.viewport.height - except AttributeError: - vertical_shift = self.interface.style.padding_top - horizontal_shift = self.interface.style.padding_left - horizontal_size_adjustment = ( - self.interface.style.padding_right + horizontal_shift - ) - vertical_size_adjustment = self.interface.style.padding_bottom - self.native.Size = Size( - width + horizontal_size_adjustment, height + vertical_size_adjustment - ) - self.native.Location = Point(x - horizontal_shift, y + vertical_shift) - def set_background_color(self, value): if value: self.native.BackColor = native_color(value) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index f03652a5e8..9809c9152b 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -337,4 +337,4 @@ def get_image_data(self): return stream.ToArray() def _points_to_pixels(self, points): - return points * 72 / self.container.viewport.dpi + return points * 72 / self.container.dpi diff --git a/winforms/src/toga_winforms/widgets/optioncontainer.py b/winforms/src/toga_winforms/widgets/optioncontainer.py index b821350d4d..38a0c0fbb3 100644 --- a/winforms/src/toga_winforms/widgets/optioncontainer.py +++ b/winforms/src/toga_winforms/widgets/optioncontainer.py @@ -1,5 +1,5 @@ +from toga_winforms.container import Container from toga_winforms.libs import WinForms -from toga_winforms.window import WinFormsViewport from .base import Widget @@ -10,7 +10,7 @@ def create(self): self.native.Selected += self.winforms_selected def add_content(self, index, text, widget): - widget.viewport = WinFormsViewport(self.native, self) + widget.viewport = Container(self.native) widget.frame = self # Add all children to the content widget. for child in widget.interface.children: diff --git a/winforms/src/toga_winforms/widgets/scrollcontainer.py b/winforms/src/toga_winforms/widgets/scrollcontainer.py index 0bbcf7eae7..944ae02c9e 100644 --- a/winforms/src/toga_winforms/widgets/scrollcontainer.py +++ b/winforms/src/toga_winforms/widgets/scrollcontainer.py @@ -1,5 +1,5 @@ +from toga_winforms.container import Container from toga_winforms.libs import WinForms -from toga_winforms.window import WinFormsViewport from .base import Widget @@ -19,7 +19,7 @@ def winforms_scroll(self, sender, event): def set_content(self, widget): self.inner_container = widget - widget.viewport = WinFormsViewport(self.native, self) + widget.viewport = Container(self.native) widget.frame = self for child in widget.interface.children: @@ -43,10 +43,6 @@ def set_window(self, window): if self.interface.content: self.interface.content.window = window - def set_on_scroll(self, on_scroll): - # Do nothing - pass - def get_vertical_position(self): return self.native.VerticalScroll.Value @@ -85,3 +81,7 @@ def maximum_vertical_position(self): @property def maximum_horizontal_position(self): return self.native.HorizontalScroll.Maximum + + def set_position(self, horizontal_position, vertical_position): + self.set_horizontal_position(horizontal_position) + self.set_vertical_position(vertical_position) diff --git a/winforms/src/toga_winforms/widgets/splitcontainer.py b/winforms/src/toga_winforms/widgets/splitcontainer.py index 0678a0d9e7..d79ed79622 100644 --- a/winforms/src/toga_winforms/widgets/splitcontainer.py +++ b/winforms/src/toga_winforms/widgets/splitcontainer.py @@ -1,5 +1,5 @@ +from toga_winforms.container import Container from toga_winforms.libs import WinForms -from toga_winforms.window import WinFormsViewport from .base import Widget @@ -25,11 +25,11 @@ def add_content(self, position, widget, flex): if position == 0: self.native.Panel1.Controls.Add(widget.native) - widget.viewport = WinFormsViewport(self.native.Panel1, self) + widget.viewport = Container(self.native.Panel1) elif position == 1: self.native.Panel2.Controls.Add(widget.native) - widget.viewport = WinFormsViewport(self.native.Panel2, self) + widget.viewport = Container(self.native.Panel2) # Turn all the weights into a fraction of 1.0 total = sum(self.interface._weight) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 9cbdf5e183..422a1a4df8 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -1,41 +1,10 @@ from toga import GROUP_BREAK, SECTION_BREAK +from .container import Container, MinimumContainer from .libs import Point, Size, WinForms -class WinFormsViewport: - def __init__(self, native, frame): - self.native = native - self.frame = frame - self.baseline_dpi = 96 - - @property - def width(self): - # Treat `native=None` as a 0x0 viewport - if self.native is None: - return 0 - return self.native.ClientSize.Width - - @property - def height(self): - # Treat `native=None` as a 0x0 viewport - if self.native is None: - return 0 - # Subtract any vertical shift of the frame. This is to allow - # for toolbars, or any other viewport-level decoration. - return self.native.ClientSize.Height - self.frame.vertical_shift - - @property - def dpi(self): - if self.native is None: - return self.baseline_dpi - return self.native.CreateGraphics().DpiX - - def refreshed(self): - pass - - -class Window: +class Window(Container): def __init__(self, interface, title, position, size): self.interface = interface self.interface._impl = self @@ -52,6 +21,7 @@ def __init__(self, interface, title, position, size): self.native.interface = self.interface self.native._impl = self self.native.FormClosing += self.winforms_FormClosing + super().__init__(self.native) self.native.MinimizeBox = self.native.interface.minimizable @@ -61,28 +31,46 @@ def __init__(self, interface, title, position, size): self.toolbar_native = None self.toolbar_items = None - if self.native.interface.resizeable: - self.native.Resize += self.winforms_resize - else: + + self.native.Resize += lambda sender, args: self.resize_content() + self.resize_content() # Store initial size + + if not self.native.interface.resizeable: self.native.FormBorderStyle = self.native.FormBorderStyle.FixedSingle self.native.MaximizeBox = False def create_toolbar(self): - self.toolbar_native = WinForms.ToolStrip() - for cmd in self.interface.toolbar: - if cmd == GROUP_BREAK: - item = WinForms.ToolStripSeparator() - elif cmd == SECTION_BREAK: - item = WinForms.ToolStripSeparator() + if self.interface.toolbar: + if self.toolbar_native: + self.toolbar_native.Items.Clear() else: - if cmd.icon is not None: - native_icon = cmd.icon._impl.native - item = WinForms.ToolStripMenuItem(cmd.text, native_icon.ToBitmap()) + # The toolbar doesn't need to be positioned, because its `Dock` property + # defaults to `Top`. + self.toolbar_native = WinForms.ToolStrip() + self.native.Controls.Add(self.toolbar_native) + + for cmd in self.interface.toolbar: + if cmd == GROUP_BREAK: + item = WinForms.ToolStripSeparator() + elif cmd == SECTION_BREAK: + item = WinForms.ToolStripSeparator() else: - item = WinForms.ToolStripMenuItem(cmd.text) - item.Click += cmd._impl.as_handler() - cmd._impl.native.append(item) - self.toolbar_native.Items.Add(item) + if cmd.icon is not None: + native_icon = cmd.icon._impl.native + item = WinForms.ToolStripMenuItem( + cmd.text, native_icon.ToBitmap() + ) + else: + item = WinForms.ToolStripMenuItem(cmd.text) + item.Click += cmd._impl.as_handler() + cmd._impl.native.append(item) + self.toolbar_native.Items.Add(item) + + elif self.toolbar_native: + self.native.Controls.Remove(self.toolbar_native) + self.toolbar_native = None + + self.resize_content() def get_position(self): return self.native.Location.X, self.native.Location.Y @@ -105,52 +93,6 @@ def set_app(self, app): self.native.Icon = icon_impl.native @property - def vertical_shift(self): - # vertical shift is the toolbar height or 0 - result = 0 - try: - result += self.native.interface._impl.toolbar_native.Height - except AttributeError: - pass - try: - result += self.native.interface._impl.native.MainMenuStrip.Height - except AttributeError: - pass - return result - - def clear_content(self): - if self.interface.content: - for child in self.interface.content.children: - child._impl.container = None - - def set_content(self, widget): - has_content = False - for control in self.native.Controls: - # The main menu and toolbar are normal in-window controls; - # however, they shouldn't be removed if window content is - # removed. - if control != self.native.MainMenuStrip and control != self.toolbar_native: - has_content = True - self.native.Controls.Remove(control) - - # The first time content is set for the window, we also need - # to add the toolbar as part of the main window content. - # We use "did we haev to remove any content" as a marker for - # whether this is the first time we're setting content. - if not has_content: - self.native.Controls.Add(self.toolbar_native) - - # Add the actual window content. - self.native.Controls.Add(widget.native) - - # Set the widget's viewport to be based on the window's content. - widget.viewport = WinFormsViewport(native=self.native, frame=self) - widget.frame = self - - # Add all children to the content widget. - for child in widget.interface.children: - child._impl.container = widget - def get_title(self): return self.native.Text @@ -166,8 +108,7 @@ def show(self): # and use that as the basis for setting the minimum window size. self.interface.content._impl.rehint() self.interface.content.style.layout( - self.interface.content, - WinFormsViewport(native=None, frame=None), + self.interface.content, MinimumContainer(self.native) ) self.native.MinimumSize = Size( int(self.interface.content.layout.width), @@ -213,7 +154,15 @@ def close(self): self._is_closing = True self.native.Close() - def winforms_resize(self, sender, args): - if self.interface.content: - # Re-layout the content - self.interface.content.refresh() + def resize_content(self): + vertical_shift = 0 + if self.toolbar_native: + vertical_shift += self.toolbar_native.Height + if self.native.MainMenuStrip: + vertical_shift += self.native.MainMenuStrip.Height + + self.native_content.Location = Point(0, vertical_shift) + super().resize_content( + self.native.ClientSize.Width, + self.native.ClientSize.Height - vertical_shift, + ) diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 7b4576ca51..004c733065 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -32,12 +32,12 @@ def __init__(self, widget): self.scale_factor = self.native.CreateGraphics().DpiX / 96 def assert_container(self, container): - container_native = container._impl.native - for control in container_native.Controls: - if Object.ReferenceEquals(control, self.native): - break - else: - raise ValueError(f"cannot find {self.native} in {container_native}") + assert self.widget._impl.container is container._impl.container + assert self.native.Parent is not None + assert Object.ReferenceEquals( + self.native.Parent, + container._impl.container.native_content, + ) def assert_not_contained(self): assert self.widget._impl.container is None @@ -109,10 +109,7 @@ def assert_layout(self, size, position): assert (self.width, self.height) == approx(size, abs=1) assert ( self.native.Left / self.scale_factor, - ( - (self.native.Top - self.widget._impl.container.vertical_shift) - / self.scale_factor - ), + self.native.Top / self.scale_factor, ) == approx(position, abs=1) async def press(self): From b6a470bb356fa60fff01245d1338800991b1c764 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sat, 8 Jul 2023 13:53:16 +0100 Subject: [PATCH 32/40] Fix "backend" tests on Winforms --- dummy/src/toga_dummy/window.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index a251ec3b04..2d714c0599 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -50,7 +50,8 @@ def __init__(self, interface, title, position, size): def create_toolbar(self): self._action("create toolbar") - @not_required_on("android") # Android inherits this method from a base class. + # Some platforms inherit this method from a base class. + @not_required_on("android", "winforms") def clear_content(self): try: widget = self._get_value("content") @@ -59,7 +60,8 @@ def clear_content(self): pass self._action("clear content") - @not_required_on("android") # Android inherits this method from a base class. + # Some platforms inherit this method from a base class. + @not_required_on("android", "winforms") def set_content(self, widget): self.container.content = widget self._action("set content", widget=widget) From 07c4e37fcb37cc247747fa04dceb6f92f054d373 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sat, 8 Jul 2023 13:54:10 +0100 Subject: [PATCH 33/40] Fix ImageView tests on Winforms with positive scale factor --- winforms/src/toga_winforms/widgets/base.py | 7 +++++++ winforms/src/toga_winforms/widgets/imageview.py | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 30e23b67d3..57140257b1 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -18,6 +18,7 @@ def __init__(self, interface): self._container = None self.native = None self.create() + self.scale = self.native.CreateGraphics().DpiX / 96 self.interface.style.reapply() @abstractmethod @@ -55,6 +56,12 @@ def container(self, container): def viewport(self): return self._container + def scale_in(self, value): + return int(round(value * self.scale)) + + def scale_out(self, value): + return int(round(value / self.scale)) + def get_tab_index(self): return self.native.TabIndex diff --git a/winforms/src/toga_winforms/widgets/imageview.py b/winforms/src/toga_winforms/widgets/imageview.py index 09326e82b6..24c71cbae7 100644 --- a/winforms/src/toga_winforms/widgets/imageview.py +++ b/winforms/src/toga_winforms/widgets/imageview.py @@ -29,8 +29,7 @@ def set_image(self, image): def rehint(self): width, height, aspect_ratio = rehint_imageview( - image=self.interface.image, - style=self.interface.style, + self.interface.image, self.interface.style, self.scale ) self.interface.intrinsic.width = width self.interface.intrinsic.height = height From 0d210105fd63aa799253418557dfdcf345e9bcdf Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 10 Jul 2023 12:51:47 +0100 Subject: [PATCH 34/40] Miscellaneous cleanups --- .../toga_android/widgets/scrollcontainer.py | 18 +++++---------- core/src/toga/widgets/scrollcontainer.py | 22 ++++++++++--------- .../api/containers/scrollcontainer.rst | 4 ++-- testbed/tests/widgets/test_scrollcontainer.py | 2 +- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/android/src/toga_android/widgets/scrollcontainer.py b/android/src/toga_android/widgets/scrollcontainer.py index f29873d147..cf2a730e67 100644 --- a/android/src/toga_android/widgets/scrollcontainer.py +++ b/android/src/toga_android/widgets/scrollcontainer.py @@ -92,20 +92,14 @@ def get_horizontal_position(self): return self.scale_out(self.hScrollView.getScrollX()) def get_max_horizontal_position(self): - if not self.get_horizontal(): - return 0 - else: - return self.scale_out( - max(0, self.native_content.getWidth() - self.native.getWidth()) - ) + return self.scale_out( + max(0, self.native_content.getWidth() - self.native.getWidth()) + ) def get_max_vertical_position(self): - if not self.get_vertical(): - return 0 - else: - return self.scale_out( - max(0, self.native_content.getHeight() - self.native.getHeight()) - ) + return self.scale_out( + max(0, self.native_content.getHeight() - self.native.getHeight()) + ) def set_position(self, horizontal_position, vertical_position): self.hScrollView.setScrollX(self.scale_in(horizontal_position)) diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index 9e5af96e26..ce9ae36293 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -131,7 +131,10 @@ def on_scroll(self, on_scroll): @property def max_horizontal_position(self) -> int: """The maximum horizontal scroll position (read-only).""" - return self._impl.get_max_horizontal_position() + if not self.horizontal: + return 0 + else: + return self._impl.get_max_horizontal_position() @property def horizontal_position(self) -> int: @@ -158,7 +161,10 @@ def horizontal_position(self, horizontal_position): @property def max_vertical_position(self) -> int: """The maximum vertical scroll position (read-only).""" - return self._impl.get_max_vertical_position() + if not self.vertical: + return 0 + else: + return self._impl.get_max_vertical_position() @property def vertical_position(self) -> int: @@ -187,23 +193,20 @@ def vertical_position(self, vertical_position): # vertical movement to appear as two separate animations. @property def position(self) -> tuple[int, int]: - """The current scroll position. + """The current scroll position, in the form (horizontal, vertical). If the value provided for either axis is negative, or greater than the maximum position in that axis, the value will be clipped to the valid range. - If scrolling is disabled in either axis, the value provided for the scroll position - in that axis will be ignored. - - :returns: A tuple containing the current scroll position in the horizontal and - vertical axis. + If scrolling is disabled in either axis, the value provided for that axis will + be ignored. """ return (self.horizontal_position, self.vertical_position) @position.setter def position(self, position): + horizontal_position, vertical_position = map(int, position) if self.horizontal: - horizontal_position = int(position[0]) if horizontal_position < 0: horizontal_position = 0 else: @@ -214,7 +217,6 @@ def position(self, position): horizontal_position = self.horizontal_position if self.vertical: - vertical_position = int(position[1]) if vertical_position < 0: vertical_position = 0 else: diff --git a/docs/reference/api/containers/scrollcontainer.rst b/docs/reference/api/containers/scrollcontainer.rst index e8e7b2e0f7..1b92816249 100644 --- a/docs/reference/api/containers/scrollcontainer.rst +++ b/docs/reference/api/containers/scrollcontainer.rst @@ -1,5 +1,5 @@ -Scroll Container -================ +ScrollContainer +=============== A container that can display a layout larger than the area of the container, with overflow controlled by scroll bars. diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 84cc6db1c0..a9f443c43a 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -215,7 +215,7 @@ async def test_enable_vertical_scrolling(widget, probe, content, on_scroll): with pytest.raises(ValueError): widget.vertical_position = 120 - # If setting a *full* position, the horizontal coordinate is ignored. + # If setting a *full* position, the vertical coordinate is ignored. widget.position = (120, 200) await probe.wait_for_scroll_completion() await probe.redraw("Vertical scroll distance is ignored") From 7bb9766bd1372ee571baf900625639506eb5b6e9 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 10 Jul 2023 13:10:36 +0100 Subject: [PATCH 35/40] Initial Winforms implementation (not working) --- winforms/src/toga_winforms/container.py | 11 ++- winforms/src/toga_winforms/widgets/base.py | 7 +- .../toga_winforms/widgets/scrollcontainer.py | 88 +++++++++---------- winforms/src/toga_winforms/window.py | 2 +- winforms/tests_backend/widgets/base.py | 4 +- .../tests_backend/widgets/scrollcontainer.py | 32 +++++++ 6 files changed, 86 insertions(+), 58 deletions(-) create mode 100644 winforms/tests_backend/widgets/scrollcontainer.py diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py index 2bbcac83e2..e8b8be1761 100644 --- a/winforms/src/toga_winforms/container.py +++ b/winforms/src/toga_winforms/container.py @@ -2,20 +2,23 @@ class BaseContainer: - def __init__(self, native_parent): + def init_container(self, native_parent): self.width = self.height = 0 self.baseline_dpi = 96 self.dpi = native_parent.CreateGraphics().DpiX class MinimumContainer(BaseContainer): + def __init__(self, native_parent): + self.init_container(native_parent) + def refreshed(self): pass class Container(BaseContainer): - def __init__(self, native_parent): - super().__init__(native_parent) + def init_container(self, native_parent): + super().init_container(native_parent) self.native_content = WinForms.Panel() native_parent.Controls.Add(self.native_content) @@ -42,7 +45,9 @@ def refreshed(self): ) def add_content(self, widget): + # The default appears to be to add new controls to the back of the Z-order. self.native_content.Controls.Add(widget.native) + widget.native.BringToFront() def remove_content(self, widget): self.native_content.Controls.Remove(widget.native) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 57140257b1..2a79bb82a8 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -39,10 +39,6 @@ def container(self): @container.setter def container(self, container): - # To obtain the correct Z-order, add children before self. - for child in self.interface.children: - child._impl.container = container - if self._container: self._container.remove_content(self) @@ -50,6 +46,9 @@ def container(self, container): if container: container.add_content(self) + for child in self.interface.children: + child._impl.container = container + self.rehint() @property diff --git a/winforms/src/toga_winforms/widgets/scrollcontainer.py b/winforms/src/toga_winforms/widgets/scrollcontainer.py index 944ae02c9e..a0613eb218 100644 --- a/winforms/src/toga_winforms/widgets/scrollcontainer.py +++ b/winforms/src/toga_winforms/widgets/scrollcontainer.py @@ -4,84 +4,76 @@ from .base import Widget -class ScrollContainer(Widget): +class ScrollContainer(Widget, Container): def create(self): self.native = WinForms.Panel() - self.native.interface = self.interface self.native.AutoScroll = True self.native.Scroll += self.winforms_scroll self.native.MouseWheel += self.winforms_scroll + self.init_container(self.native) def winforms_scroll(self, sender, event): - if self.interface.on_scroll is not None: - self.interface.on_scroll(self.interface) + self.interface.on_scroll(None) - def set_content(self, widget): - self.inner_container = widget + def resize_content(self): + client_size = self.native.ClientSize # Size not including scroll bars + super().resize_content(client_size.Width, client_size.Height) + if self.interface.content: + self.interface.content.refresh() - widget.viewport = Container(self.native) - widget.frame = self + self.native.HorizontalScroll.Maximum = max( + 0, self.native_content.Width - client_size.Width + ) + self.native.VerticalScroll.Maximum = max( + 0, self.native_content.Height - client_size.Height + ) - for child in widget.interface.children: - child._impl.container = widget + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) + self.resize_content() - self.native.Controls.Add(self.inner_container.native) + def get_horizontal(self): + return self.native.HorizontalScroll.Enabled def set_horizontal(self, value): + if not value: + self.native.HorizontalScroll.Value = 0 + self.native.AutoScroll = False self.native.HorizontalScroll.Enabled = value self.native.HorizontalScroll.Visible = value self.native.AutoScroll = True + self.resize_content() + + def get_vertical(self): + return self.native.VerticalScroll.Enabled def set_vertical(self, value): + if not value: + self.native.VerticalScroll.Value = 0 + self.native.AutoScroll = False self.native.VerticalScroll.Enabled = value self.native.VerticalScroll.Visible = value self.native.AutoScroll = True + self.resize_content() def set_window(self, window): if self.interface.content: self.interface.content.window = window def get_vertical_position(self): - return self.native.VerticalScroll.Value - - def set_vertical_position(self, vertical_position): - if vertical_position < 0 or vertical_position > self.maximum_vertical_position: - raise ValueError( - "Vertical position should be between 0 and {}, got {}".format( - self.maximum_vertical_position, vertical_position - ) - ) - self.native.VerticalScroll.Value = vertical_position - if self.interface.on_scroll is not None: - self.interface.on_scroll(self.interface) + return self.scale_out(self.native.VerticalScroll.Value) def get_horizontal_position(self): - return self.native.HorizontalScroll.Value - - def set_horizontal_position(self, horizontal_position): - if ( - horizontal_position < 0 - or horizontal_position > self.maximum_horizontal_position - ): - raise ValueError( - "Horizontal position should be between 0 and {}, got {}".format( - self.maximum_horizontal_position, horizontal_position - ) - ) - self.native.HorizontalScroll.Value = horizontal_position - if self.interface.on_scroll is not None: - self.interface.on_scroll(self.interface) - - @property - def maximum_vertical_position(self): - return self.native.VerticalScroll.Maximum - - @property - def maximum_horizontal_position(self): - return self.native.HorizontalScroll.Maximum + return self.scale_out(self.native.HorizontalScroll.Value) + + def get_max_vertical_position(self): + return self.scale_out(self.native.VerticalScroll.Maximum) + + def get_max_horizontal_position(self): + return self.scale_out(self.native.HorizontalScroll.Maximum) def set_position(self, horizontal_position, vertical_position): - self.set_horizontal_position(horizontal_position) - self.set_vertical_position(vertical_position) + self.native.HorizontalScroll.Value = self.scale_in(horizontal_position) + self.native.VerticalScroll.Value = self.scale_in(vertical_position) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 422a1a4df8..f9a350cb0b 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -21,7 +21,7 @@ def __init__(self, interface, title, position, size): self.native.interface = self.interface self.native._impl = self self.native.FormClosing += self.winforms_FormClosing - super().__init__(self.native) + self.init_container(self.native) self.native.MinimizeBox = self.native.interface.minimizable diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 004c733065..975ccf0741 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -79,11 +79,11 @@ def hidden(self): @property def width(self): - return self.native.Width / self.scale_factor + return round(self.native.Width / self.scale_factor) @property def height(self): - return self.native.Height / self.scale_factor + return round(self.native.Height / self.scale_factor) def assert_width(self, min_width, max_width): assert ( diff --git a/winforms/tests_backend/widgets/scrollcontainer.py b/winforms/tests_backend/widgets/scrollcontainer.py new file mode 100644 index 0000000000..7878f1093c --- /dev/null +++ b/winforms/tests_backend/widgets/scrollcontainer.py @@ -0,0 +1,32 @@ +from System.Windows.Forms import Panel + +from .base import SimpleProbe + + +class ScrollContainerProbe(SimpleProbe): + native_class = Panel + + def __init__(self, widget): + super().__init__(widget) + + assert self.native.Controls.Count == 1 + self.native_content = self.native.Controls[0] + assert isinstance(self.native_content, Panel) + + @property + def has_content(self): + return self.native_content.Controls.Count != 0 + + @property + def document_height(self): + return round(self.native_content.Height / self.scale_factor) + + @property + def document_width(self): + return round(self.native_content.Width / self.scale_factor) + + async def scroll(self): + self.native.VerticalScroll.Value = 100 + + async def wait_for_scroll_completion(self): + pass From 2004cb80b8d33ea231c895bc77e1ca98f715fe28 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 10 Jul 2023 19:19:32 +0100 Subject: [PATCH 36/40] Winforms to 100% --- .../toga_android/widgets/scrollcontainer.py | 2 - .../tests_backend/widgets/scrollcontainer.py | 1 + .../tests_backend/widgets/scrollcontainer.py | 1 + core/src/toga/widgets/scrollcontainer.py | 4 +- core/tests/widgets/test_scrollcontainer.py | 11 +- .../scrollcontainer/scrollcontainer/app.py | 32 +++-- gtk/tests_backend/widgets/scrollcontainer.py | 1 + iOS/tests_backend/widgets/scrollcontainer.py | 1 + testbed/tests/widgets/test_scrollcontainer.py | 12 +- .../toga_winforms/widgets/scrollcontainer.py | 128 ++++++++++++------ .../tests_backend/widgets/scrollcontainer.py | 11 +- 11 files changed, 129 insertions(+), 75 deletions(-) diff --git a/android/src/toga_android/widgets/scrollcontainer.py b/android/src/toga_android/widgets/scrollcontainer.py index cf2a730e67..c2e43344fd 100644 --- a/android/src/toga_android/widgets/scrollcontainer.py +++ b/android/src/toga_android/widgets/scrollcontainer.py @@ -66,8 +66,6 @@ def create(self): def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) self.resize_content(width, height) - if self.interface.content: - self.interface.content.refresh() def get_vertical(self): return self.vScrollListener.is_scrolling_enabled diff --git a/android/tests_backend/widgets/scrollcontainer.py b/android/tests_backend/widgets/scrollcontainer.py index 2ca3ce2ef3..21ac804432 100644 --- a/android/tests_backend/widgets/scrollcontainer.py +++ b/android/tests_backend/widgets/scrollcontainer.py @@ -7,6 +7,7 @@ class ScrollContainerProbe(SimpleProbe): native_class = ScrollView + scrollbar_inset = 0 def __init__(self, widget): super().__init__(widget) diff --git a/cocoa/tests_backend/widgets/scrollcontainer.py b/cocoa/tests_backend/widgets/scrollcontainer.py index 24c117c57f..3e42bd1216 100644 --- a/cocoa/tests_backend/widgets/scrollcontainer.py +++ b/cocoa/tests_backend/widgets/scrollcontainer.py @@ -11,6 +11,7 @@ class ScrollContainerProbe(SimpleProbe): native_class = NSScrollView + scrollbar_inset = 0 @property def has_content(self): diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index ce9ae36293..2e1d3adb0c 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -89,13 +89,13 @@ def content(self, widget): if widget: widget.app = self.app widget.window = self.window - self._impl.set_content(widget._impl) else: self._impl.set_content(None) self._content = widget - self.refresh() + if widget: + widget.refresh() @property def vertical(self) -> bool: diff --git a/core/tests/widgets/test_scrollcontainer.py b/core/tests/widgets/test_scrollcontainer.py index 629fa50ffa..114049aff6 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -71,8 +71,8 @@ def test_widget_created_with_values(content, on_scroll_handler): widget=content._impl, ) - # The scroll container has been refreshed - assert_action_performed(scroll_container, "refresh") + # The content has been refreshed + assert_action_performed(content, "refresh") # The scroll handler hasn't been invoked on_scroll_handler.assert_not_called() @@ -177,8 +177,8 @@ def test_set_content(app, window, scroll_container, content): widget=new_content._impl, ) - # The scroll container has been refreshed - assert_action_performed(scroll_container, "refresh") + # The content has been refreshed + assert_action_performed(new_content, "refresh") # The content has been assigned assert scroll_container.content == new_content @@ -211,9 +211,6 @@ def test_clear_content(app, window, scroll_container, content): # The content has been assigned to the widget assert_action_performed_with(scroll_container, "set content", widget=None) - # The scroll container has been refreshed - assert_action_performed(scroll_container, "refresh") - # The content has been cleared assert scroll_container.content is None diff --git a/examples/scrollcontainer/scrollcontainer/app.py b/examples/scrollcontainer/scrollcontainer/app.py index b26aa741f1..0a19513bf4 100644 --- a/examples/scrollcontainer/scrollcontainer/app.py +++ b/examples/scrollcontainer/scrollcontainer/app.py @@ -21,26 +21,29 @@ class ScrollContainerApp(toga.App): def startup(self): main_box = toga.Box(style=Pack(direction=COLUMN)) + self.hswitch = toga.Switch( + "Horizontal", + value=False, + on_change=self.handle_hscrolling, + ) self.vswitch = toga.Switch( - "Vert", + "Vertical", value=True, on_change=self.handle_vscrolling, ) - self.hswitch = toga.Switch( - "Horiz", - value=False, - on_change=self.handle_hscrolling, + self.wide_switch = toga.Switch( + "Wide", + value=True, + on_change=lambda widget: self.update_content(), ) - self.big_switch = toga.Switch( - "Big", + self.tall_switch = toga.Switch( + "Tall", value=True, on_change=lambda widget: self.update_content(), ) main_box.add( - toga.Box( - style=Pack(direction=ROW), - children=[self.vswitch, self.hswitch, self.big_switch], - ) + toga.Box(children=[self.hswitch, self.vswitch]), + toga.Box(children=[self.wide_switch, self.tall_switch]), ) self.inner_box = toga.Box( @@ -101,16 +104,17 @@ def handle_vscrolling(self, widget): def update_content(self): self.inner_box.clear() - width, height = (10, 50) if self.big_switch.value else (2, 2) + width = 10 if self.wide_switch.value else 2 + height = 30 if self.tall_switch.value else 2 for x in range(height): label_text = f"Label {x}" self.inner_box.add(Item(width, label_text)) def on_scroll(self, scroller): - self.hswitch.text = "Horiz " + ( + self.hswitch.text = "Horizontal " + ( f"({scroller.horizontal_position} / {scroller.max_horizontal_position})" ) - self.vswitch.text = "Vert " + ( + self.vswitch.text = "Vertical " + ( f"({scroller.vertical_position} / {scroller.max_vertical_position})" ) diff --git a/gtk/tests_backend/widgets/scrollcontainer.py b/gtk/tests_backend/widgets/scrollcontainer.py index 3ad048b805..364f17c9e9 100644 --- a/gtk/tests_backend/widgets/scrollcontainer.py +++ b/gtk/tests_backend/widgets/scrollcontainer.py @@ -5,6 +5,7 @@ class ScrollContainerProbe(SimpleProbe): native_class = Gtk.ScrolledWindow + scrollbar_inset = 0 @property def has_content(self): diff --git a/iOS/tests_backend/widgets/scrollcontainer.py b/iOS/tests_backend/widgets/scrollcontainer.py index fa7d3021fd..1fbadb4948 100644 --- a/iOS/tests_backend/widgets/scrollcontainer.py +++ b/iOS/tests_backend/widgets/scrollcontainer.py @@ -9,6 +9,7 @@ class ScrollContainerProbe(SimpleProbe): native_class = UIScrollView + scrollbar_inset = 0 @property def has_content(self): diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index a9f443c43a..2ea0132524 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -78,7 +78,7 @@ async def widget(content, on_scroll): async def test_clear_content(widget, probe, small_content): "Widget content can be cleared and reset" - assert probe.document_width == probe.width + assert probe.document_width == probe.width - probe.scrollbar_inset assert probe.document_height == approx(6000, abs=100) # TODO: see DPI note widget.content = None @@ -254,7 +254,7 @@ async def test_enable_vertical_scrolling(widget, probe, content, on_scroll): async def test_vertical_scroll(widget, probe, on_scroll): "The widget can be scrolled vertically." - assert probe.document_width == probe.width + assert probe.document_width == probe.width - probe.scrollbar_inset assert probe.document_height > probe.height assert probe.document_height == approx(6000, abs=100) # TODO: see DPI note @@ -327,7 +327,7 @@ async def test_horizontal_scroll(widget, probe, content, on_scroll): assert probe.document_width > probe.width assert probe.document_width == approx(20000, abs=100) # TODO: see DPI note - assert probe.document_height == probe.height + assert probe.document_height == probe.height - probe.scrollbar_inset assert widget.max_horizontal_position == approx( probe.document_width - probe.width, abs=1 @@ -431,7 +431,7 @@ async def test_scroll_both(widget, probe, content, on_scroll): await probe.redraw("Scroll to bottom left") assert widget.horizontal_position == 0 assert widget.vertical_position == approx( - probe.document_height - probe.height, abs=1 + probe.document_height - probe.height + probe.scrollbar_inset, abs=1 ) on_scroll.assert_called_with(widget) on_scroll.reset_mock() @@ -440,10 +440,10 @@ async def test_scroll_both(widget, probe, content, on_scroll): await probe.wait_for_scroll_completion() await probe.redraw("Scroll to bottom right") assert widget.horizontal_position == approx( - probe.document_width - probe.width, abs=1 + probe.document_width - probe.width + probe.scrollbar_inset, abs=1 ) assert widget.vertical_position == approx( - probe.document_height - probe.height, abs=1 + probe.document_height - probe.height + probe.scrollbar_inset, abs=1 ) on_scroll.assert_called_with(widget) on_scroll.reset_mock() diff --git a/winforms/src/toga_winforms/widgets/scrollcontainer.py b/winforms/src/toga_winforms/widgets/scrollcontainer.py index a0613eb218..b91b39f257 100644 --- a/winforms/src/toga_winforms/widgets/scrollcontainer.py +++ b/winforms/src/toga_winforms/widgets/scrollcontainer.py @@ -1,79 +1,123 @@ +from System.Drawing import Point, Size +from System.Windows.Forms import Panel, SystemInformation +from travertino.node import Node + from toga_winforms.container import Container -from toga_winforms.libs import WinForms from .base import Widget +# On Windows, scroll bars usually appear only when the content is larger than the +# container. However, this complicates layout. For example, if the content fits in +# the container horizontally but is taller vertically, we then need to do a second +# layout pass with a slightly narrower horizontal size to account for the vertical +# scroll bar (https://stackoverflow.com/questions/28418026). +# +# A previous attempt to avoid this, by making the scroll bars always visible, was not +# successful (see commit "Initial Winforms implementation" on 2023-07-10). Although the +# ScrollableControl API does provide Visible and Enabled properties for each scroll bar, +# they behave in confusing and undocumented ways, e.g.: +# +# * https://stackoverflow.com/questions/8690643 +# * https://stackoverflow.com/questions/5489273 +# +# So the current implementation just uses the default AutoScroll behavior, and does a +# second layout pass where necessary. + class ScrollContainer(Widget, Container): def create(self): - self.native = WinForms.Panel() + self.native = Panel() self.native.AutoScroll = True + self.init_container(self.native) + + # The Scroll event only fires on direct interaction with the scroll bar. It + # doesn't fire when using the mouse wheel, and it doesn't fire when setting + # AutoScrollPosition either, despite the documentation saying otherwise. self.native.Scroll += self.winforms_scroll self.native.MouseWheel += self.winforms_scroll - self.init_container(self.native) def winforms_scroll(self, sender, event): self.interface.on_scroll(None) - def resize_content(self): - client_size = self.native.ClientSize # Size not including scroll bars - super().resize_content(client_size.Width, client_size.Height) - if self.interface.content: - self.interface.content.refresh() - - self.native.HorizontalScroll.Maximum = max( - 0, self.native_content.Width - client_size.Width - ) - self.native.VerticalScroll.Maximum = max( - 0, self.native_content.Height - client_size.Height - ) - def set_bounds(self, x, y, width, height): super().set_bounds(x, y, width, height) - self.resize_content() + self.resize_content(width, height) + + def refreshed(self): + full_width, full_height = (self.width, self.height) + inset_width = full_width - SystemInformation.VerticalScrollBarWidth + inset_height = full_height - SystemInformation.HorizontalScrollBarHeight + layout = self.interface.content.layout + + # Temporarily reduce container size to account for scroll bars (see explanation + # at the top of this file). + def apply_insets(): + need_scrollbar = False + if self.vertical and layout.height > self.height: + need_scrollbar = True + self.width = inset_width + if self.horizontal and layout.width > self.width: + need_scrollbar = True + self.height = inset_height + return need_scrollbar + + if apply_insets(): + # Bypass Widget.refresh to avoid a recursive call to `refreshed`. + Node.refresh(self.interface.content, self) + + # In borderline cases, adding one scroll bar may cause the other one to be + # needed as well. + apply_insets() + + # Crop any non-scrollable dimensions to the available size. + self.native_content.Size = Size( + max(self.width, layout.width if self.horizontal else 0), + max(self.height, layout.height if self.vertical else 0), + ) + + # Restore the original container size so it'll be used in the next call to + # `refresh` or `resize_content`. + self.width, self.height = full_width, full_height def get_horizontal(self): - return self.native.HorizontalScroll.Enabled + return self.horizontal def set_horizontal(self, value): + self.horizontal = value if not value: - self.native.HorizontalScroll.Value = 0 - - self.native.AutoScroll = False - self.native.HorizontalScroll.Enabled = value - self.native.HorizontalScroll.Visible = value - self.native.AutoScroll = True - self.resize_content() + self.interface.on_scroll(None) + if self.interface.content: + self.interface.content.refresh() def get_vertical(self): - return self.native.VerticalScroll.Enabled + return self.vertical def set_vertical(self, value): + self.vertical = value if not value: - self.native.VerticalScroll.Value = 0 - - self.native.AutoScroll = False - self.native.VerticalScroll.Enabled = value - self.native.VerticalScroll.Visible = value - self.native.AutoScroll = True - self.resize_content() - - def set_window(self, window): + self.interface.on_scroll(None) if self.interface.content: - self.interface.content.window = window + self.interface.content.refresh() def get_vertical_position(self): - return self.scale_out(self.native.VerticalScroll.Value) + return self.scale_out(abs(self.native.AutoScrollPosition.Y)) def get_horizontal_position(self): - return self.scale_out(self.native.HorizontalScroll.Value) + return self.scale_out(abs(self.native.AutoScrollPosition.X)) def get_max_vertical_position(self): - return self.scale_out(self.native.VerticalScroll.Maximum) + return self.scale_out( + max(0, self.native_content.Height - self.native.ClientSize.Height) + ) def get_max_horizontal_position(self): - return self.scale_out(self.native.HorizontalScroll.Maximum) + return self.scale_out( + max(0, self.native_content.Width - self.native.ClientSize.Width) + ) def set_position(self, horizontal_position, vertical_position): - self.native.HorizontalScroll.Value = self.scale_in(horizontal_position) - self.native.VerticalScroll.Value = self.scale_in(vertical_position) + self.native.AutoScrollPosition = Point( + self.scale_in(horizontal_position), + self.scale_in(vertical_position), + ) + self.interface.on_scroll(None) diff --git a/winforms/tests_backend/widgets/scrollcontainer.py b/winforms/tests_backend/widgets/scrollcontainer.py index 7878f1093c..150197d5b6 100644 --- a/winforms/tests_backend/widgets/scrollcontainer.py +++ b/winforms/tests_backend/widgets/scrollcontainer.py @@ -1,10 +1,12 @@ -from System.Windows.Forms import Panel +from System.Drawing import Point +from System.Windows.Forms import Panel, ScrollEventArgs, ScrollEventType from .base import SimpleProbe class ScrollContainerProbe(SimpleProbe): native_class = Panel + scrollbar_inset = 17 def __init__(self, widget): super().__init__(widget) @@ -26,7 +28,12 @@ def document_width(self): return round(self.native_content.Width / self.scale_factor) async def scroll(self): - self.native.VerticalScroll.Value = 100 + if self.document_height > self.height: + position = 100 + self.native.AutoScrollPosition = Point(0, position) + self.native.OnScroll( + ScrollEventArgs(ScrollEventType.ThumbPosition, position) + ) async def wait_for_scroll_completion(self): pass From 6f6516eb426456002c5b2cbd6ca05eedd49923d6 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 11 Jul 2023 18:58:31 +0100 Subject: [PATCH 37/40] Add comments explaining scale_in and scale_out --- android/src/toga_android/widgets/base.py | 2 ++ winforms/src/toga_winforms/widgets/base.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 208fc5f13c..ae86a20e67 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -80,9 +80,11 @@ def container(self, container): def viewport(self): return self._container + # Convert CSS pixels to native pixels def scale_in(self, value): return int(round(value * self.container.scale)) + # Convert native pixels to CSS pixels def scale_out(self, value): return int(round(value / self.container.scale)) diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 2a79bb82a8..da6a93cf99 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -55,9 +55,11 @@ def container(self, container): def viewport(self): return self._container + # Convert CSS pixels to native pixels def scale_in(self, value): return int(round(value * self.scale)) + # Convert native pixels to CSS pixels def scale_out(self, value): return int(round(value / self.scale)) From 1bf22d1ba7456e0de74e1f593f23e605cef44e80 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 11 Jul 2023 19:18:44 +0100 Subject: [PATCH 38/40] Remove redundant checks in GTK which are now covered by the interface layer --- gtk/src/toga_gtk/widgets/scrollcontainer.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/gtk/src/toga_gtk/widgets/scrollcontainer.py b/gtk/src/toga_gtk/widgets/scrollcontainer.py index 2d62723cb2..2fbe8538c8 100644 --- a/gtk/src/toga_gtk/widgets/scrollcontainer.py +++ b/gtk/src/toga_gtk/widgets/scrollcontainer.py @@ -72,9 +72,6 @@ def set_vertical(self, value): self.interface.on_scroll(None) def get_max_vertical_position(self): - if not self.get_vertical(): - return 0 - return max( 0, int( @@ -87,9 +84,6 @@ def get_vertical_position(self): return int(self.native.get_vadjustment().get_value()) def get_max_horizontal_position(self): - if not self.get_horizontal(): - return 0 - return max( 0, int( From 096ac1f23d5678f5dfd830c7ee02f3af8f92b3b8 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 12 Jul 2023 10:48:50 +0800 Subject: [PATCH 39/40] Cleaned up 2 stray uncovered lines. --- cocoa/src/toga_cocoa/widgets/scrollcontainer.py | 3 +-- iOS/src/toga_iOS/widgets/scrollcontainer.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 8680868a49..1f2418ffe1 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -28,8 +28,7 @@ def didScroll_(self, note) -> None: def refreshContent(self): # Now that we have an updated size for the ScrollContainer, re-evaluate # the size of the document content - if self.interface._content: - self.interface._content.refresh() + self.interface._content.refresh() class ScrollContainer(Widget): diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index 9c39ec48bd..fa40e949ba 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -16,8 +16,7 @@ def scrollViewDidScroll_(self, scrollView) -> None: @objc_method def refreshContent(self): - if self.interface._content: - self.interface._content.refresh() + self.interface._content.refresh() class ScrollContainer(Widget): From 0e1302a5cd223161f5bdac37c23a7e16740466db Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 12 Jul 2023 11:06:12 +0800 Subject: [PATCH 40/40] Add a test for performing a layout with no content. --- cocoa/src/toga_cocoa/widgets/scrollcontainer.py | 5 +++-- iOS/src/toga_iOS/widgets/scrollcontainer.py | 5 ++++- testbed/tests/widgets/test_scrollcontainer.py | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 1f2418ffe1..9894f780db 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -27,8 +27,9 @@ def didScroll_(self, note) -> None: @objc_method def refreshContent(self): # Now that we have an updated size for the ScrollContainer, re-evaluate - # the size of the document content - self.interface._content.refresh() + # the size of the document content (assuming there is a document) + if self.interface._content: + self.interface._content.refresh() class ScrollContainer(Widget): diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index fa40e949ba..f1fafc731a 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -16,7 +16,10 @@ def scrollViewDidScroll_(self, scrollView) -> None: @objc_method def refreshContent(self): - self.interface._content.refresh() + # Now that we have an updated size for the ScrollContainer, re-evaluate + # the size of the document content (assuming there is a document) + if self.interface._content: + self.interface._content.refresh() class ScrollContainer(Widget): diff --git a/testbed/tests/widgets/test_scrollcontainer.py b/testbed/tests/widgets/test_scrollcontainer.py index 2ea0132524..e10eaa03ae 100644 --- a/testbed/tests/widgets/test_scrollcontainer.py +++ b/testbed/tests/widgets/test_scrollcontainer.py @@ -475,3 +475,20 @@ async def test_manual_scroll(widget, probe, content, on_scroll): assert widget.horizontal_position == 0 assert widget.vertical_position == 0 on_scroll.assert_not_called() + + +async def test_no_content(widget, probe, content): + "The content of the scroll container can be cleared" + widget.content = None + await probe.redraw("Content of the scroll container has been cleared") + + # Force a refresh to see the impact of a set_bounds() when there's + # no inner content. + widget.refresh() + await probe.redraw("Scroll container layout has been refreshed") + + widget.content = content + await probe.redraw("Content of the scroll container has been restored") + + widget.refresh() + await probe.redraw("Scroll container layout has been refreshed")