diff --git a/android/src/toga_android/widgets/box.py b/android/src/toga_android/widgets/box.py index ef2c0eae9b..d4f0ad112b 100644 --- a/android/src/toga_android/widgets/box.py +++ b/android/src/toga_android/widgets/box.py @@ -10,9 +10,6 @@ def create(self): self.native = RelativeLayout(MainActivity.singletonThis) def set_child_bounds(self, widget, x, y, width, height): - # Avoid setting child boundaries if `create()` has not been called. - if not widget.native: - return # We assume `widget.native` has already been added to this `RelativeLayout`. # # We use `topMargin` and `leftMargin` to perform absolute layout. Not very diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index f0bfab91fa..ee6be9499d 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -5,6 +5,8 @@ from android.view import ViewTreeObserver from toga.fonts import SYSTEM +from .properties import toga_color + class LayoutListener(dynamic_proxy(ViewTreeObserver.OnGlobalLayoutListener)): def __init__(self): @@ -80,5 +82,9 @@ def height(self): # Return the value in DP return self.native.getHeight() / self.scale_factor + @property + def background_color(self): + return toga_color(self.native.getBackground().getColor()) + def press(self): self.native.performClick() diff --git a/android/tests_backend/widgets/box.py b/android/tests_backend/widgets/box.py new file mode 100644 index 0000000000..9db2709ccc --- /dev/null +++ b/android/tests_backend/widgets/box.py @@ -0,0 +1,7 @@ +from java import jclass + +from .base import SimpleProbe + + +class BoxProbe(SimpleProbe): + native_class = jclass("android.widget.RelativeLayout") diff --git a/android/tests_backend/widgets/label.py b/android/tests_backend/widgets/label.py index 1598da5c77..c8708cef07 100644 --- a/android/tests_backend/widgets/label.py +++ b/android/tests_backend/widgets/label.py @@ -11,10 +11,6 @@ class LabelProbe(SimpleProbe): def color(self): return toga_color(self.native.getCurrentTextColor()) - @property - def background_color(self): - return toga_color(self.native.getBackground().getColor()) - @property def text(self): return str(self.native.getText()) diff --git a/changes/1820.feature.rst b/changes/1820.feature.rst new file mode 100644 index 0000000000..8bc73e8e69 --- /dev/null +++ b/changes/1820.feature.rst @@ -0,0 +1 @@ +The Box widget now has 100% test coverage, and complete API documentation. diff --git a/cocoa/src/toga_cocoa/widgets/box.py b/cocoa/src/toga_cocoa/widgets/box.py index bc5454dd65..df5ebec834 100644 --- a/cocoa/src/toga_cocoa/widgets/box.py +++ b/cocoa/src/toga_cocoa/widgets/box.py @@ -11,11 +11,6 @@ def isFlipped(self) -> bool: # Default Cocoa coordinate frame is around the wrong way. return True - @objc_method - def display(self) -> None: - self.layer.needsDisplay = True - self.layer.displayIfNeeded() - class Box(Widget): def create(self): diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index bad58265cf..09dbce14ee 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -1,7 +1,10 @@ import asyncio +from toga.colors import TRANSPARENT from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM +from .properties import toga_color + class SimpleProbe: def __init__(self, widget): @@ -55,5 +58,15 @@ def width(self): def height(self): return self.native.frame.size.height + @property + def background_color(self): + if self.native.drawsBackground: + if self.native.backgroundColor: + return toga_color(self.native.backgroundColor) + else: + return None + else: + return TRANSPARENT + def press(self): self.native.performClick(None) diff --git a/cocoa/tests_backend/widgets/box.py b/cocoa/tests_backend/widgets/box.py new file mode 100644 index 0000000000..0bc31e433e --- /dev/null +++ b/cocoa/tests_backend/widgets/box.py @@ -0,0 +1,7 @@ +from toga_cocoa.libs import NSView + +from .base import SimpleProbe + + +class BoxProbe(SimpleProbe): + native_class = NSView diff --git a/cocoa/tests_backend/widgets/label.py b/cocoa/tests_backend/widgets/label.py index b26fdfac6b..d4faa7e066 100644 --- a/cocoa/tests_backend/widgets/label.py +++ b/cocoa/tests_backend/widgets/label.py @@ -1,4 +1,3 @@ -from toga.colors import TRANSPARENT from toga_cocoa.libs import NSTextField from .base import SimpleProbe @@ -16,16 +15,6 @@ def text(self): def color(self): return toga_color(self.native.textColor) - @property - def background_color(self): - if self.native.drawsBackground: - if self.native.backgroundColor: - return toga_color(self.native.backgroundColor) - else: - return None - else: - return TRANSPARENT - @property def font(self): return toga_font(self.native.font) diff --git a/core/src/toga/widgets/box.py b/core/src/toga/widgets/box.py index 4001127020..97ba28a080 100644 --- a/core/src/toga/widgets/box.py +++ b/core/src/toga/widgets/box.py @@ -1,38 +1,24 @@ -import warnings - from .base import Widget class Box(Widget): - """This is a Widget that contains other widgets, but has no rendering or - interaction of its own. - - Args: - id (str): An identifier for this widget. - style (:class:~colosseum.CSSNode`): An optional style object. If no - style is provided then a new one will be created for the widget. - children (``list`` of :class:`~toga.Widget`): An optional list of child - Widgets that will be in this box. - """ - def __init__( self, id=None, style=None, children=None, - factory=None, # DEPRECATED! ): - super().__init__(id=id, style=style) + """Create a new Box container widget. + + Inherits from :class:`~toga.widgets.base.Widget`. - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### + :param text: The text to display on the button. + :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 children: An optional list of children for to add to the Box. + """ + super().__init__(id=id, style=style) self._children = [] if children: diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index 2a434b873f..4d1713ad96 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -76,12 +76,6 @@ def test_window(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_box_created(self): - with self.assertWarns(DeprecationWarning): - widget = toga.Box(children=[toga.Widget()], factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_canvas_created(self): with self.assertWarns(DeprecationWarning): widget = toga.Canvas(factory=self.factory) diff --git a/core/tests/widgets/test_box.py b/core/tests/widgets/test_box.py index c0f41a2054..bfd5e2a57d 100644 --- a/core/tests/widgets/test_box.py +++ b/core/tests/widgets/test_box.py @@ -1,16 +1,33 @@ import toga -from toga_dummy.utils import TestCase +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, +) -class BoxTests(TestCase): - def setUp(self): - super().setUp() - self.children = [toga.Widget()] - self.box = toga.Box(children=self.children) +def test_create_box(): + "A Box can be created." + box = toga.Box() + # Round trip the impl/interface + assert box._impl.interface == box - def test_widget_created(self): - self.assertEqual(self.box._impl.interface, self.box) - self.assertActionPerformed(self.box, "create Box") + assert_action_performed(box, "create Box") + assert_action_not_performed(box, "add child") - def test_children_added(self): - self.assertEqual(self.box._children, self.children) + +def test_create_box_with_children(): + "A Box can be created with children." + child1 = toga.Box() + child2 = toga.Box() + box = toga.Box(children=[child1, child2]) + + # Round trip the impl/interface + assert box._impl.interface == box + + assert_action_performed(box, "create Box") + # The impl-level add-child will not be called, + # because the box hasn't been assigned to a window + assert_action_not_performed(box, "add child") + + # But the box will have children. + assert box.children == [child1, child2] diff --git a/docs/reference/api/containers/box.rst b/docs/reference/api/containers/box.rst index 2b7c8ea2a1..3330d0afa8 100644 --- a/docs/reference/api/containers/box.rst +++ b/docs/reference/api/containers/box.rst @@ -1,6 +1,8 @@ Box === +A generic container for other widgets. Used to construct layouts. + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,57 +10,40 @@ Box :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!(Box|Component))'} -The box is a generic container for widgets, allowing you to construct layouts. - Usage ----- -A box can be instantiated with no children and the children added later: - -.. code-block:: Python - - import toga - - box = toga.Box('box1') - - button = toga.Button('Hello world', on_press=button_handler) - box.add(button) - -To create boxes within boxes, use the children argument: +An empty Box can be constructed without any children, with children added to the +box after construction: .. code-block:: Python import toga - box_a = toga.Box('box_a') - box_b = toga.Box('box_b') + box = toga.Box() - box = toga.Box('box', children=[box_a, box_b]) + label1 = toga.Label('Hello') + label2 = toga.Label('World') -Box Styling ------------ + box.add(label1) + box.add(label2) -Styling of boxes can be done during instantiation of the Box: +Alternatively, children can be specified at the time the box is constructed: .. code-block:: Python import toga - from toga.style import Pack - from toga.style.pack import COLUMN - - box = toga.Box(id='box', style=Pack(direction=COLUMN, padding_top=10)) - -Styles can be also be updated on an existing instance: -.. code-block:: Python - - import toga - from toga.style import Pack - from toga.style.pack import COLUMN + label1 = toga.Label('Hello') + label2 = toga.Label('World') - box = toga.Box(id='box', style=Pack(direction=COLUMN)) + box = toga.Box(children=[label1, label2]) - box.style.update(padding_top=10) +In most apps, a layout is constructed by building a tree of boxes inside boxes, +with concrete widgets (such as :class:`~toga.widgets.label.Label` or +:class:`~toga.widgets.button.Button`) forming the leaf nodes of the tree. Style +directives can be applied to enforce padding around the outside of the box, +direction of child stacking inside the box, and background color of the box. Reference --------- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index c56e983beb..aa63a35b2e 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -23,7 +23,7 @@ TimePicker,General Widget,:class:`~toga.widgets.timepicker.TimePicker`,An input Tree,General Widget,:class:`~toga.widgets.tree.Tree`,Tree of data,|b|,|b|,|b|,,, WebView,General Widget,:class:`~toga.widgets.webview.WebView`,A panel for displaying HTML,|b|,|b|,|b|,|b|,|b|, Widget,General Widget,:class:`~toga.widgets.base.Widget`,The base widget,|b|,|b|,|b|,|b|,|b|,|b| -Box,Layout Widget,:class:`~toga.widgets.box.Box`,Container for components,|b|,|b|,|b|,|b|,|b|,|b| +Box,Layout Widget,:class:`~toga.widgets.box.Box`,Container for components,|y|,|y|,|y|,|y|,|y|,|b| ScrollContainer,Layout Widget,:class:`~toga.widgets.scrollcontainer.ScrollContainer`,Scrollable Container,|b|,|b|,|b|,|b|,|b|, SplitContainer,Layout Widget,:class:`~toga.widgets.splitcontainer.SplitContainer`,Split Container,|b|,|b|,|b|,,, OptionContainer,Layout Widget,:class:`~toga.widgets.optioncontainer.OptionContainer`,Option Container,|b|,|b|,|b|,,, diff --git a/iOS/src/toga_iOS/widgets/box.py b/iOS/src/toga_iOS/widgets/box.py index ad83b30e61..fa57d6542c 100644 --- a/iOS/src/toga_iOS/widgets/box.py +++ b/iOS/src/toga_iOS/widgets/box.py @@ -1,28 +1,12 @@ -from rubicon.objc import objc_method, objc_property from travertino.size import at_least from toga_iOS.libs import UIView from toga_iOS.widgets.base import Widget -class TogaView(UIView): - interface = objc_property(object, weak=True) - impl = objc_property(object, weak=True) - - @objc_method - def isFlipped(self) -> bool: - # Default Cocoa coordinate frame is around the wrong way. - return True - - @objc_method - def display(self) -> None: - self.layer.setNeedsDisplay_(True) - self.layer.displayIfNeeded() - - class Box(Widget): def create(self): - self.native = TogaView.alloc().init() + self.native = UIView.alloc().init() self.native.interface = self.interface self.native.impl = self diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index db2ce267a1..3b40650e09 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -1,7 +1,10 @@ import asyncio +from toga.colors import TRANSPARENT from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM -from toga_iOS.libs import NSRunLoop +from toga_iOS.libs import NSRunLoop, UIColor + +from .properties import toga_color # From UIControl.h UIControlEventTouchDown = 1 << 0 @@ -87,5 +90,12 @@ def width(self): def height(self): return self.native.frame.size.height + @property + def background_color(self): + if self.native.backgroundColor == UIColor.clearColor: + return TRANSPARENT + else: + return toga_color(self.native.backgroundColor) + def press(self): self.native.sendActionsForControlEvents(UIControlEventTouchDown) diff --git a/iOS/tests_backend/widgets/box.py b/iOS/tests_backend/widgets/box.py new file mode 100644 index 0000000000..32e111f4e3 --- /dev/null +++ b/iOS/tests_backend/widgets/box.py @@ -0,0 +1,7 @@ +from toga_iOS.libs import UIView + +from .base import SimpleProbe + + +class BoxProbe(SimpleProbe): + native_class = UIView diff --git a/iOS/tests_backend/widgets/label.py b/iOS/tests_backend/widgets/label.py index e2d870bbdc..b0c97a24a9 100644 --- a/iOS/tests_backend/widgets/label.py +++ b/iOS/tests_backend/widgets/label.py @@ -1,5 +1,4 @@ -from toga.colors import TRANSPARENT -from toga_iOS.libs import UIColor, UILabel +from toga_iOS.libs import UILabel from .base import SimpleProbe from .properties import toga_alignment, toga_color, toga_font @@ -19,13 +18,6 @@ def text(self): def color(self): return toga_color(self.native.textColor) - @property - def background_color(self): - if self.native.backgroundColor == UIColor.clearColor: - return TRANSPARENT - else: - return toga_color(self.native.backgroundColor) - @property def font(self): return toga_font(self.native.font) diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index ca0d967170..17671d9d26 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -1,3 +1,5 @@ +from pytest import approx + from toga.colors import RED, TRANSPARENT, color as named_color from toga.fonts import BOLD, FANTASY, ITALIC, NORMAL, SERIF, SYSTEM from toga.style.pack import COLUMN @@ -147,6 +149,50 @@ async def test_background_color_transparent(widget, probe): assert_color(probe.background_color, TRANSPARENT) +async def test_flex_widget_size(widget, probe): + "The widget can expand in either axis." + # Container is initially a non-flex row box. Paint it red so we can see it. + widget.style.background_color = RED + await probe.redraw() + + # Check the initial widget size + # Match isn't exact because of pixel scaling on some platforms + assert probe.width == approx(100, rel=0.01) + assert probe.height == approx(100, rel=0.01) + + # Drop the fixed height, and make the widget flexible + widget.style.flex = 1 + del widget.style.height + + # Widget should now be 100 pixels wide, but as tall as the container. + await probe.redraw() + assert probe.width == approx(100, rel=0.01) + assert probe.height > 300 + + # Make the parent a COLUMN box + del widget.style.width + widget.parent.style.direction = COLUMN + + # Widget should now be the size of the container + await probe.redraw() + assert probe.width > 300 + assert probe.height > 300 + + # Revert to fixed height + widget.style.height = 150 + + await probe.redraw() + assert probe.width > 300 + assert probe.height == approx(150, rel=0.01) + + # Revert to fixed width + widget.style.width = 150 + + await probe.redraw() + assert probe.width == approx(150, rel=0.01) + assert probe.height == approx(150, rel=0.01) + + async def test_flex_horizontal_widget_size(widget, probe): "Check that a widget that is flexible in the horizontal axis resizes as expected" # Container is initially a non-flex row box. diff --git a/testbed/tests/widgets/test_box.py b/testbed/tests/widgets/test_box.py new file mode 100644 index 0000000000..93efac2b6e --- /dev/null +++ b/testbed/tests/widgets/test_box.py @@ -0,0 +1,15 @@ +import pytest + +import toga +from toga.style import Pack + +from .properties import ( # noqa: F401 + test_background_color, + test_background_color_reset, + test_flex_widget_size, +) + + +@pytest.fixture +async def widget(): + return toga.Box(style=Pack(width=100, height=100))