diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index d4dc1f5336..77022ec207 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) @@ -72,16 +61,16 @@ 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 @@ -89,9 +78,8 @@ def content(self, widget): self._content = widget self._impl.set_content(widget._impl) - self.refresh() - widget.refresh() + self.refresh() def refresh_sublayouts(self): """Refresh the layout and appearance of this widget.""" @@ -99,44 +87,40 @@ 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. + """ return self._impl.get_horizontal_position() @horizontal_position.setter @@ -145,14 +129,18 @@ 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. + """ 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 17227e56dd..3e4dc9667d 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -106,12 +106,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..db11d02f7c 100644 --- a/core/tests/widgets/test_scrollcontainer.py +++ b/core/tests/widgets/test_scrollcontainer.py @@ -1,94 +1,168 @@ -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 assert_action_performed + + +@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 refreshed at least once to cause a + assert_action_performed(content, "refresh") + + # The scroll handler hasn't been invoked + on_scroll_handler.assert_not_called() + + +# 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) + + +@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_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_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)