diff --git a/changes/1984.feature.rst b/changes/1984.feature.rst new file mode 100644 index 0000000000..caadeee2d1 --- /dev/null +++ b/changes/1984.feature.rst @@ -0,0 +1 @@ +The SplitContainer widget now has 100% test coverage, and complete API documentation. diff --git a/changes/1984.removal.1.rst b/changes/1984.removal.1.rst new file mode 100644 index 0000000000..8b3e8f37a9 --- /dev/null +++ b/changes/1984.removal.1.rst @@ -0,0 +1 @@ +Support for SplitContainers with more than 2 panels of content has been removed. diff --git a/changes/1984.removal.2.rst b/changes/1984.removal.2.rst new file mode 100644 index 0000000000..7d194c8aa1 --- /dev/null +++ b/changes/1984.removal.2.rst @@ -0,0 +1 @@ +Support for 3-tuple form of specifying SplitContainer items, used to prevent panels from resizing, has been removed. diff --git a/cocoa/src/toga_cocoa/container.py b/cocoa/src/toga_cocoa/container.py index db081d2fae..be3986ee55 100644 --- a/cocoa/src/toga_cocoa/container.py +++ b/cocoa/src/toga_cocoa/container.py @@ -19,14 +19,13 @@ def isFlipped(self) -> bool: class BaseContainer: - def __init__(self, content=None, on_refresh=None): + def __init__(self, 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._content = None self.on_refresh = on_refresh # macOS always renders at 96dpi. Scaling is handled # transparently at the level of the screen compositor. @@ -60,12 +59,9 @@ def refreshed(self): class MinimumContainer(BaseContainer): - 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) + def __init__(self): + """A container for evaluating the minumum possible size for a layout""" + super().__init__() self.width = 0 self.height = 0 @@ -73,7 +69,6 @@ def __init__(self, content=None): class Container(BaseContainer): def __init__( self, - content=None, min_width=100, min_height=100, layout_native=None, @@ -83,7 +78,6 @@ def __init__( 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 @@ -94,7 +88,8 @@ def __init__( :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__(on_refresh=on_refresh) + self.native = TogaView.alloc().init() self.layout_native = self.native if layout_native is None else layout_native @@ -133,8 +128,18 @@ def width(self): def height(self): return self.layout_native.frame.size.height - def set_min_width(self, width): + @property + def min_width(self): + return self._min_width_constraint.constant + + @min_width.setter + def min_width(self, width): self._min_width_constraint.constant = width - def set_min_height(self, height): + @property + def min_height(self): + return self._min_height_constraint.constant + + @min_height.setter + def min_height(self, height): self._min_height_constraint.constant = height diff --git a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py index 478532a459..c1ea3eb8ab 100644 --- a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py +++ b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py @@ -34,9 +34,6 @@ def create(self): self.native.autohidesScrollers = False self.native.borderType = NSBezelBorder - # Disable all autolayout functionality on the outer widget - self.native.translatesAutoresizingMaskIntoConstraints = False - # Create the actual text widget self.native_text = TogaTextView.alloc().init() self.native_text.interface = self.interface diff --git a/cocoa/src/toga_cocoa/widgets/splitcontainer.py b/cocoa/src/toga_cocoa/widgets/splitcontainer.py index 1591befa64..30d8ddd349 100644 --- a/cocoa/src/toga_cocoa/widgets/splitcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/splitcontainer.py @@ -1,84 +1,132 @@ -from rubicon.objc import objc_method, objc_property +from rubicon.objc import SEL, objc_method, objc_property from travertino.size import at_least -from toga_cocoa.container import Container -from toga_cocoa.libs import NSObject, NSSize, NSSplitView +from toga.constants import Direction +from toga_cocoa.container import Container, MinimumContainer +from toga_cocoa.libs import NSSplitView from .base import Widget -class TogaSplitViewDelegate(NSObject): +class TogaSplitView(NSSplitView): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) - @objc_method - def splitView_resizeSubviewsWithOldSize_(self, view, size: NSSize) -> None: - # Turn all the weights into a fraction of 1.0 - total = sum(self.interface._weight) - self.interface._weight = [weight / total for weight in self.interface._weight] - - # Mark the subviews as needing adjustment - view.adjustSubviews() - - # Set the splitter positions based on the new weight fractions. - cumulative = 0.0 - if self.interface.direction == self.interface.VERTICAL: - for i, weight in enumerate(self.interface._weight[:-1]): - cumulative += weight - view.setPosition(view.frame.size.width * cumulative, ofDividerAtIndex=i) - else: - for i, weight in enumerate(self.interface._weight[:-1]): - cumulative += weight - view.setPosition( - view.frame.size.height * cumulative, ofDividerAtIndex=i - ) - @objc_method def splitViewDidResizeSubviews_(self, notification) -> None: - # If the window is actually visible, and the split has moved, - # a resize of all the content panels is required. The refresh - # needs to be directed at the root container holding the widget, - # as the splitview may not be the root container. - if self.interface.window and self.interface.window._impl.native.isVisible: - self.interface.refresh() - self.impl.on_resize() + # If the split has moved, a resize of all the content panels is required. + for container in self.impl.sub_containers: + container.content.interface.refresh() + # Apply any pending split + self.performSelector( + SEL("applySplit"), + withObject=None, + afterDelay=0, + ) -class SplitContainer(Widget): - """Cocoa SplitContainer implementation. + @objc_method + def applySplit(self) -> None: + if self.impl._split_proportion: + if self.interface.direction == self.interface.VERTICAL: + position = self.impl._split_proportion * self.frame.size.width + else: + position = self.impl._split_proportion * self.frame.size.height + + self.setPosition(position, ofDividerAtIndex=0) + self.impl._split_proportion = None - Todo: - * update the minimum width of the whole SplitContainer based on the content of its sub layouts. - """ +class SplitContainer(Widget): def create(self): - self.native = NSSplitView.alloc().init() + self.native = TogaSplitView.alloc().init() + self.native.interface = self.interface + self.native.impl = self + self.native.delegate = self.native + + self.sub_containers = [Container(), Container()] + self.native.addSubview(self.sub_containers[0].native) + self.native.addSubview(self.sub_containers[1].native) - self.delegate = TogaSplitViewDelegate.alloc().init() - self.delegate.interface = self.interface - self.delegate.impl = self - self.native.delegate = self.delegate + self._split_proportion = None # Add the layout constraints self.add_constraints() - def add_content(self, position, widget, flex): - # TODO: add flex option to the implementation - container = Container(content=widget) - - # 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. - container.native.translatesAutoresizingMaskIntoConstraints = True - self.native.addSubview(container.native) + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) + for container in self.sub_containers: + if container.content: + container.content.interface.refresh() + + # Apply any pending split + self.native.performSelector( + SEL("applySplit"), + withObject=None, + afterDelay=0, + ) + + def set_content(self, content, flex): + # Clear any existing content + for container in self.sub_containers: + container.content = None + + for index, widget in enumerate(content): + # Compute the minimum layout for the content + if widget: + widget.interface.style.layout(widget.interface, MinimumContainer()) + min_width = widget.interface.layout.width + min_height = widget.interface.layout.height + + # Create a container with that minimum size, and assign the widget as content + self.sub_containers[index].min_width = min_width + self.sub_containers[index].min_height = min_height + + self.sub_containers[index].content = widget + else: + self.sub_containers[index].min_width = 0 + self.sub_containers[index].min_height = 0 + + # We now know the initial positions of the split. However, we can't *set* the + # because Cocoa requires a pixel position, and the widget isn't visible yet. + # So - store the split; and when have a displayed widget, apply that proportion. + self._split_proportion = flex[0] / sum(flex) + + def get_direction(self): + return Direction.VERTICAL if self.native.isVertical() else Direction.HORIZONTAL def set_direction(self, value): self.native.vertical = value - def on_resize(self): - pass - def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + # This is a SWAG (scientific wild-ass guess). There doesn't appear to be + # an actual API to get the true size of the splitter. 10px seems enough. + SPLITTER_WIDTH = 10 + if self.interface.direction == self.interface.HORIZONTAL: + # When the splitter is horizontal, the splitcontainer must be + # at least as wide as it's widest sub-container, and at least + # as tall as the minimum height of all subcontainers, plus the + # height of the splitter itself. Enforce a minimum size in both + # axies + min_width = self.interface._MIN_WIDTH + min_height = 0 + for sub_container in self.sub_containers: + min_width = max(min_width, sub_container.min_width) + min_height += sub_container.min_height + + min_height = max(min_height, self.interface._MIN_HEIGHT) + SPLITTER_WIDTH + else: + # When the splitter is vertical, the splitcontainer must be + # at least as tall as it's tallest sub-container, and at least + # as wide as the minimum width of all subcontainers, plus the + # width of the splitter itself. + min_width = 0 + min_height = self.interface._MIN_HEIGHT + for sub_container in self.sub_containers: + min_width += sub_container.min_width + min_height = max(min_height, sub_container.min_height) + + min_width = max(min_width, self.interface._MIN_WIDTH) + SPLITTER_WIDTH + + self.interface.intrinsic.width = at_least(min_width) + self.interface.intrinsic.height = at_least(min_height) diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 3cf2f1b04a..5e7e80a7f5 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -239,8 +239,8 @@ def show(self): self.interface.content, MinimumContainer(), ) - self.container.set_min_width(self.interface.content.layout.width) - self.container.set_min_height(self.interface.content.layout.height) + self.container.min_width = self.interface.content.layout.width + self.container.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/splitcontainer.py b/cocoa/tests_backend/widgets/splitcontainer.py new file mode 100644 index 0000000000..94ede713cb --- /dev/null +++ b/cocoa/tests_backend/widgets/splitcontainer.py @@ -0,0 +1,23 @@ +import asyncio + +from toga_cocoa.libs import NSSplitView + +from .base import SimpleProbe + + +class SplitContainerProbe(SimpleProbe): + native_class = NSSplitView + border_size = 0 + direction_change_preserves_position = False + + def move_split(self, position): + self.native.setPosition(position, ofDividerAtIndex=0) + + async def wait_for_split(self): + sub1 = self.impl.sub_containers[0].native.frame.size + position = sub1.height, sub1.width + current = None + while position != current: + position = current + await asyncio.sleep(0.05) + current = sub1.height, sub1.width diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index c8d0b5da7b..5f2dd2cefd 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -1 +1,9 @@ +from enum import Enum + from travertino.constants import * # noqa: F401, F403 + + +class Direction(Enum): + "The direction a given property should act" + HORIZONTAL = 0 + VERTICAL = 1 diff --git a/core/src/toga/widgets/divider.py b/core/src/toga/widgets/divider.py index 5064f317e6..546385d373 100644 --- a/core/src/toga/widgets/divider.py +++ b/core/src/toga/widgets/divider.py @@ -1,27 +1,31 @@ from __future__ import annotations +from toga.constants import Direction + from .base import Widget class Divider(Widget): - HORIZONTAL = 0 - VERTICAL = 1 + HORIZONTAL = Direction.HORIZONTAL + VERTICAL = Direction.VERTICAL def __init__( self, id=None, style=None, - direction=HORIZONTAL, + direction: Direction = HORIZONTAL, ): """Create a new divider line. Inherits from :class:`toga.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 direction: The direction in which the divider will be drawn. - Defaults to ``Divider.HORIZONTAL`` + :param style: A style object. If no style is provided, a default style will be + applied to the widget. + :param direction: The direction in which the divider will be drawn. Either + :attr:`~toga.constants.Direction.HORIZONTAL` or + :attr:`~toga.constants.Direction.VERTICAL`; defaults to + :attr:`~toga.constants.Direction.HORIZONTAL` """ super().__init__(id=id, style=style) @@ -47,11 +51,8 @@ def focus(self): pass @property - def direction(self): - """The direction in which the visual separator will be drawn. - - :returns: ``Divider.HORIZONTAL`` or ``Divider.VERTICAL`` - """ + def direction(self) -> Direction: + """The direction in which the visual separator will be drawn.""" return self._impl.get_direction() @direction.setter diff --git a/core/src/toga/widgets/splitcontainer.py b/core/src/toga/widgets/splitcontainer.py index 613451c8ee..0ab2d68c4e 100644 --- a/core/src/toga/widgets/splitcontainer.py +++ b/core/src/toga/widgets/splitcontainer.py @@ -1,51 +1,37 @@ -import warnings +from __future__ import annotations + +from toga.constants import Direction from .base import Widget class SplitContainer(Widget): - """A SplitContainer displays two widgets vertically or horizontally next to each - other with a movable divider. - - 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. - direction: The direction for the container split, - either `SplitContainer.HORIZONTAL` or `SplitContainer.VERTICAL` - content(``list`` of :class:`~toga.Widget`): The list of components to be - split or tuples of components to be split and adjusting parameters - in the following order: - widget (:class:`~toga.Widget`): The widget that will be added. - weight (float): Specifying the weighted splits. - flex (Boolean): Should the content expand when the widget is resized. (optional) - """ - - HORIZONTAL = False - VERTICAL = True + HORIZONTAL = Direction.HORIZONTAL + VERTICAL = Direction.VERTICAL def __init__( self, id=None, style=None, - direction=VERTICAL, - content=None, - factory=None, # DEPRECATED! + direction: Direction = Direction.VERTICAL, + content: tuple[Widget | None | tuple, Widget | None | tuple] = (None, None), ): + """Create a new SplitContainer. + + Inherits from :class:`toga.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 direction: The direction in which the divider will be drawn. Either + :attr:`~toga.constants.Direction.HORIZONTAL` or + :attr:`~toga.constants.Direction.VERTICAL`; defaults to + :attr:`~toga.constants.Direction.VERTICAL` + :param content: Initial :any:`content` of the container. Defaults to both panels + being empty. + """ 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._direction = direction - self._content = [] - self._weight = [] + self._content = (None, None) # Create a platform specific implementation of a SplitContainer self._impl = self.factory.SplitContainer(interface=self) @@ -54,53 +40,85 @@ def __init__( self.direction = direction @property - def content(self): - """The sub layouts of the `SplitContainer`. + def enabled(self) -> bool: + """Is the widget currently enabled? i.e., can the user interact with the widget? + + SplitContainer 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; SplitContainer cannot accept input focus" + pass + + # The inner tuple's full type is tuple[Widget | None, float], but that would make + # the documentation unreadable. + @property + def content(self) -> tuple[Widget | None | tuple, Widget | None | tuple]: + """The widgets displayed in the SplitContainer. + + This property accepts a sequence of exactly 2 elements, each of which can be + either: - Returns: - A ``list`` of :class:`toga.Widget`. Each element of the list - is a sub layout of the `SplitContainer` + * A :any:`Widget` to display in the panel. + * ``None``, to make the panel empty. + * A tuple consisting of a Widget (or ``None``) and the initial flex value to + apply to that panel in the split, which must be greater than 0. - Raises: - ValueError: If the list is less than two elements long. + If a flex value isn't specified, a value of 1 is assumed. + + When reading this property, only the widgets are returned, not the flex values. """ return self._content @content.setter def content(self, content): - if content is None: - self._content = None - return - - if len(content) < 2: - raise ValueError("SplitContainer content must have at least 2 elements") - - self._content = [] - for position, item in enumerate(content): + try: + if len(content) != 2: + raise TypeError() + except TypeError: + raise ValueError( + "SplitContainer content must be a sequence with exactly 2 elements" + ) + + _content = [] + flex = [] + for item in content: if isinstance(item, tuple): if len(item) == 2: - widget, weight = item - flex = True - elif len(item) == 3: - widget, weight, flex = item + widget, flex_value = item + if flex_value <= 0: + raise ValueError( + "The flex value for an item in a SplitContainer must be >0" + ) else: raise ValueError( - "The tuple of the content must be the length of " - "2 or 3 parameters, with the following order: " - "widget, weight and flex (optional)" + "An item in SplitContainer content must be a 2-tuple " + "containing the widget, and the flex weight to assign to that " + "widget." ) else: widget = item - weight = 1.0 - flex = True + flex_value = 1 + + _content.append(widget) + flex.append(flex_value) - self._content.append(widget) - self._weight.append(weight) + if widget: + widget.app = self.app + widget.window = self.window - widget.app = self.app - widget.window = self.window - self._impl.add_content(position, widget._impl, flex) - widget.refresh() + self._impl.set_content( + tuple(w._impl if w is not None else None for w in _content), + flex, + ) + self._content = tuple(_content) + self.refresh() @Widget.app.setter def app(self, app): @@ -108,8 +126,8 @@ def app(self, app): Widget.app.fset(self, app) # Also assign the app to the content in the container - if self.content: - for content in self.content: + for content in self.content: + if content: content.app = app @Widget.window.setter @@ -118,28 +136,16 @@ def window(self, window): Widget.window.fset(self, window) # Also assign the window to the content in the container - if self._content: - for content in self._content: + for content in self._content: + if content: content.window = window - def refresh_sublayouts(self): - """Refresh the layout and appearance of this widget.""" - if self.content is None: - return - for widget in self.content: - widget.refresh() - @property - def direction(self): - """The direction of the split. - - Returns: - True if `True` for vertical, `False` for horizontal. - """ - return self._direction + def direction(self) -> Direction: + """The direction of the split""" + return self._impl.get_direction() @direction.setter def direction(self, value): - self._direction = value self._impl.set_direction(value) self.refresh() diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index 6969febd16..f27ed66829 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -86,12 +86,6 @@ def test_option_container_created(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_split_container_created(self): - with self.assertWarns(DeprecationWarning): - widget = toga.SplitContainer(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_table_created(self): with self.assertWarns(DeprecationWarning): widget = toga.Table( diff --git a/core/tests/widgets/test_splitcontainer.py b/core/tests/widgets/test_splitcontainer.py index 9252b30045..6f6f88263c 100644 --- a/core/tests/widgets/test_splitcontainer.py +++ b/core/tests/widgets/test_splitcontainer.py @@ -1,119 +1,356 @@ -from unittest import mock +import pytest import toga -from toga_dummy.utils import TestCase, TestStyle - - -class SplitContainerTests(TestCase): - def setUp(self): - super().setUp() - self.content = [toga.Box(style=TestStyle()), toga.Box(style=TestStyle())] - self.split = toga.SplitContainer(style=TestStyle()) - - def test_widget_created(self): - self.assertEqual(self.split._impl.interface, self.split) - self.assertActionPerformed(self.split, "create SplitContainer") - - def test_setting_content_valid_input(self): - new_content = [toga.Box(style=TestStyle()), toga.Box(style=TestStyle())] - self.split.content = new_content - self.assertEqual(self.split.content, new_content) - - def test_setting_content_false_input(self): - with self.assertRaises(Exception): - self.split.content = toga.Box() - - with self.assertRaises(ValueError): - self.split.content = [toga.Box()] - - def test_setting_content_invokes_impl_method(self): - new_content = [toga.Box(style=TestStyle()), toga.Box(style=TestStyle())] - self.split.content = new_content - self.assertActionPerformedWith( - self.split, "add content", position=0, widget=new_content[0]._impl - ) - self.assertActionPerformedWith( - self.split, "add content", position=1, widget=new_content[1]._impl - ) - - def test_direction_property_default(self): - self.assertEqual(self.split.direction, True) - - def test_setting_direction_property_invokes_impl_method(self): - new_value = False - self.split.direction = new_value - self.assertValueSet(self.split, "direction", new_value) - - def test_setting_content_valid_input_with_tuple_of2(self): - new_content = [ - (toga.Box(style=TestStyle()), 1.2), - (toga.Box(style=TestStyle()), 2.5), - ] - self.split.content = new_content - self.assertEqual(self.split._weight[0], 1.2) - self.assertEqual(self.split._weight[1], 2.5) - - def test_setting_content_valid_input_with_tuple_of3(self): - new_content = [ - (toga.Box(style=TestStyle()), 1.2), - (toga.Box(style=TestStyle()), 2.5, False), +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, +) + + +@pytest.fixture +def app(): + return toga.App("Split Container Test", "org.beeware.toga.split_container") + + +@pytest.fixture +def window(): + return toga.Window() + + +@pytest.fixture +def content1(): + return toga.Box() + + +@pytest.fixture +def content2(): + return toga.Box() + + +@pytest.fixture +def content3(): + return toga.Box() + + +@pytest.fixture +def splitcontainer(content1, content2): + return toga.SplitContainer(content=[content1, content2]) + + +def test_widget_created(): + "A scroll container can be created with no arguments" + splitcontainer = toga.SplitContainer() + assert splitcontainer._impl.interface == splitcontainer + assert_action_performed(splitcontainer, "create SplitContainer") + + assert splitcontainer.content == (None, None) + assert splitcontainer.direction == toga.SplitContainer.VERTICAL + + +def test_widget_created_with_values(content1, content2): + "A split container can be created with arguments" + splitcontainer = toga.SplitContainer( + content=[content1, content2], + direction=toga.SplitContainer.HORIZONTAL, + ) + assert splitcontainer._impl.interface == splitcontainer + assert_action_performed(splitcontainer, "create SplitContainer") + + assert splitcontainer.content == (content1, content2) + assert splitcontainer.direction == toga.SplitContainer.HORIZONTAL + + # The content has been assigned to the widget + assert_action_performed_with( + splitcontainer, + "set content", + content=(content1._impl, content2._impl), + flex=[1, 1], + ) + + # The split container has been refreshed + assert_action_performed(splitcontainer, "refresh") + + +@pytest.mark.parametrize( + "include_left, include_right", + [ + (False, False), + (False, True), + (True, False), + (True, True), + ], +) +def test_assign_to_app(app, content1, content2, include_left, include_right): + """If the widget is assigned to an app, the content is also assigned""" + splitcontainer = toga.SplitContainer( + content=[ + content1 if include_left else None, + content2 if include_right else None, ] - self.split.content = new_content - self.assertEqual(self.split._weight[0], 1.2) - self.assertEqual(self.split._weight[1], 2.5) - self.assertActionPerformedWith( - self.split, - "add content", - position=0, - widget=new_content[0][0]._impl, - flex=True, - ) - self.assertActionPerformedWith( - self.split, - "add content", - position=1, - widget=new_content[1][0]._impl, - flex=False, - ) - - def test_setting_content_valid_input_with_tuple_of_more3(self): - new_content = [ - (toga.Box(style=TestStyle()), 1.2, True, True), - (toga.Box(style=TestStyle()), 2.5, False, True), + ) + + # Split container is initially unassigned + assert splitcontainer.app is None + + # Assign the split container to the app + splitcontainer.app = app + + # Split container is on the app + assert splitcontainer.app == app + + # Content is also on the app + if include_left: + assert content1.app == app + + if include_right: + assert content2.app == app + + +def test_assign_to_app_no_content(app): + """If the widget is assigned to an app, and there is no content, there's no error""" + splitcontainer = toga.SplitContainer() + + # Scroll container is initially unassigned + assert splitcontainer.app is None + + # Assign the scroll container to the app + splitcontainer.app = app + + # Scroll container is on the app + assert splitcontainer.app == app + + +@pytest.mark.parametrize( + "include_left, include_right", + [ + (False, False), + (False, True), + (True, False), + (True, True), + ], +) +def test_assign_to_window(window, content1, content2, include_left, include_right): + """If the widget is assigned to a window, the content is also assigned""" + splitcontainer = toga.SplitContainer( + content=[ + content1 if include_left else None, + content2 if include_right else None, ] - with self.assertRaises(ValueError): - self.split.content = new_content - - def test_set_window_without_content(self): - window = mock.Mock() - self.split.window = window - self.assertEqual(self.split.window, window) - - def test_set_window_with_content(self): - self.split.content = self.content - for content in self.content: - self.assertIsNone(content.window) - - window = mock.Mock() - self.split.window = window - - self.assertEqual(self.split.window, window) - for content in self.content: - self.assertEqual(content.window, window) - - def test_set_app_without_content(self): - app = mock.Mock() - self.split.app = app - self.assertEqual(self.split.app, app) - - def test_set_app_with_content(self): - self.split.content = self.content - for content in self.content: - self.assertIsNone(content.app) - - app = mock.Mock() - self.split.app = app - - self.assertEqual(self.split.app, app) - for content in self.content: - self.assertEqual(content.app, app) + ) + + # Split container is initially unassigned + assert splitcontainer.window is None + + # Assign the split container to the window + splitcontainer.window = window + + # Split container is on the window + assert splitcontainer.window == window + + # Content is also on the window + if include_left: + assert content1.window == window + + if include_right: + assert content2.window == window + + +def test_assign_to_window_no_content(window): + """If the widget is assigned to an app, and there is no content, there's no error""" + splitcontainer = toga.SplitContainer() + + # Scroll container is initially unassigned + assert splitcontainer.window is None + + # Assign the scroll container to the window + splitcontainer.window = window + + # Scroll container is on the window + assert splitcontainer.window == window + + +def test_disable_no_op(splitcontainer): + "SplitContainer doesn't have a disabled state" + # Enabled by default + assert splitcontainer.enabled + + # Try to disable the widget + splitcontainer.enabled = False + + # Still enabled. + assert splitcontainer.enabled + + +def test_focus_noop(splitcontainer): + "Focus is a no-op." + + splitcontainer.focus() + assert_action_not_performed(splitcontainer, "focus") + + +@pytest.mark.parametrize( + "include_left, include_right", + [ + (False, False), + (False, True), + (True, False), + (True, True), + ], +) +def test_set_content_widgets( + splitcontainer, + content1, + content2, + content3, + include_left, + include_right, +): + """Widget content can be set to a list of widgets""" + splitcontainer.content = [ + content2 if include_left else None, + content3 if include_right else None, + ] + + assert_action_performed_with( + splitcontainer, + "set content", + content=( + content2._impl if include_left else None, + content3._impl if include_right else None, + ), + flex=[1, 1], + ) + + # The split container has been refreshed + assert_action_performed(splitcontainer, "refresh") + + +@pytest.mark.parametrize( + "include_left, include_right", + [ + (False, False), + (False, True), + (True, False), + (True, True), + ], +) +def test_set_content_flex( + splitcontainer, + content2, + content3, + include_left, + include_right, +): + """Widget content can be set to a list of widgets with flex values""" + splitcontainer.content = [ + (content2 if include_left else None, 2), + (content3 if include_right else None, 3), + ] + + assert_action_performed_with( + splitcontainer, + "set content", + content=( + content2._impl if include_left else None, + content3._impl if include_right else None, + ), + flex=[2, 3], + ) + + # The split container has been refreshed + assert_action_performed(splitcontainer, "refresh") + + +@pytest.mark.parametrize( + "include_left, include_right", + [ + (False, False), + (False, True), + (True, False), + (True, True), + ], +) +def test_set_content_flex_mixed( + splitcontainer, + content2, + content3, + include_left, + include_right, +): + """Flex values will be defaulted if missing""" + splitcontainer.content = [ + content2 if include_left else None, + (content3 if include_right else None, 3), + ] + + assert_action_performed_with( + splitcontainer, + "set content", + content=( + content2._impl if include_left else None, + content3._impl if include_right else None, + ), + flex=[1, 3], + ) + + # The split container has been refreshed + assert_action_performed(splitcontainer, "refresh") + + +@pytest.mark.parametrize( + "content, message", + [ + ( + None, + r"SplitContainer content must be a sequence with exactly 2 elements", + ), + ( + [], + r"SplitContainer content must be a sequence with exactly 2 elements", + ), + ( + [toga.Box()], + r"SplitContainer content must be a sequence with exactly 2 elements", + ), + ( + [toga.Box(), toga.Box(), toga.Box()], + r"SplitContainer content must be a sequence with exactly 2 elements", + ), + ( + [toga.Box(), (toga.Box(),)], + r"An item in SplitContainer content must be a 2-tuple containing " + r"the widget, and the flex weight to assign to that widget.", + ), + ( + [toga.Box(), (toga.Box(), 42, True)], + r"An item in SplitContainer content must be a 2-tuple containing " + r"the widget, and the flex weight to assign to that widget.", + ), + ( + [toga.Box(), (toga.Box(), 0)], + r"The flex value for an item in a SplitContainer must be >0", + ), + ( + [toga.Box(), (toga.Box(), -1)], + r"The flex value for an item in a SplitContainer must be >0", + ), + ], +) +def test_set_content_invalid(splitcontainer, content, message): + """Widget content can only be set to valid values""" + + with pytest.raises(ValueError, match=message): + splitcontainer.content = content + + +def test_direction(splitcontainer): + """The direction of the splitcontainer can be changed""" + + splitcontainer.direction = toga.SplitContainer.HORIZONTAL + + # The direction has been set + assert splitcontainer.direction == toga.SplitContainer.HORIZONTAL + + # The split container has been refreshed + assert_action_performed(splitcontainer, "refresh") diff --git a/docs/reference/api/constants.rst b/docs/reference/api/constants.rst new file mode 100644 index 0000000000..f2e36b1614 --- /dev/null +++ b/docs/reference/api/constants.rst @@ -0,0 +1,6 @@ +Constants +========= + +.. automodule:: toga.constants + :members: + :undoc-members: diff --git a/docs/reference/api/containers/optioncontainer.rst b/docs/reference/api/containers/optioncontainer.rst index 89fc3c9587..32c594836b 100644 --- a/docs/reference/api/containers/optioncontainer.rst +++ b/docs/reference/api/containers/optioncontainer.rst @@ -1,5 +1,5 @@ -Option Container -================ +OptionContainer +=============== .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) diff --git a/docs/reference/api/containers/splitcontainer.rst b/docs/reference/api/containers/splitcontainer.rst index c596a47fd1..8fea0b6805 100644 --- a/docs/reference/api/containers/splitcontainer.rst +++ b/docs/reference/api/containers/splitcontainer.rst @@ -1,5 +1,11 @@ -Split Container -=============== +SplitContainer +============== + +A container that divides an area into two panels with a movable border. + +.. figure:: /reference/images/SplitContainer.png + :align: center + :width: 300px .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) @@ -8,10 +14,6 @@ Split Container :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!(SplitContainer|Component))'} -The split container is a container with a movable split and the option for 2 or 3 elements. - -.. figure:: /reference/images/SplitContainer.jpeg - :align: center Usage ----- @@ -20,30 +22,49 @@ Usage import toga - split = toga.SplitContainer() left_container = toga.Box() right_container = toga.ScrollContainer() - split.content = [left_container, right_container] + split = toga.SplitContainer(content=[left_container, right_container]) -Setting split direction ------------------------ - -Split direction is set on instantiation using the ``direction`` keyword argument. Direction is vertical by default. +Content can be specified when creating the widget, or after creation by assigning +the ``content`` attribute. The direction of the split can also be configured, either +at time of creation, or by setting the ``direction`` attribute: .. code-block:: python import toga + from toga.constants import Direction + + split = toga.SplitContainer(direction=Direction.HORIZONTAL) - split = toga.SplitContainer(direction=toga.SplitContainer.HORIZONTAL) left_container = toga.Box() right_container = toga.ScrollContainer() split.content = [left_container, right_container] +By default, the space of the SplitContainer will be evenly divided between the +two panels. To specify an uneven split, you can provide a flex value when specifying +content. In the following example, there will be a 60/40 split between the left +and right panels. + +.. code-block:: python + + import toga + + split = toga.SplitContainer() + left_container = toga.Box() + right_container = toga.ScrollContainer() + + split.content = [(left_container, 3), (right_container, 2)] + +This only specifies the initial split; the split can be modified by the user +once it is displayed. + Reference --------- .. autoclass:: toga.SplitContainer :members: :undoc-members: + :exclude-members: HORIZONTAL, VERTICAL, window, app diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index be76ef655b..0ec2d6daf0 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -62,7 +62,8 @@ Layout widgets :doc:`Box ` A generic container for other widgets. Used to construct layouts. :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:`SplitContainer ` A container that divides an area into two panels with a movable + border. :doc:`OptionContainer ` Option Container ==================================================================== ======================================================================== @@ -74,8 +75,8 @@ Resources ==================================================================== ======================================================================== :doc:`App Paths ` A mechanism for obtaining platform-appropriate file system locations for an application. - :doc:`Font ` Fonts :doc:`Command ` Command + :doc:`Font ` Fonts :doc:`Group ` Command group :doc:`Icon ` An icon for buttons, menus, etc :doc:`Image ` An image @@ -86,6 +87,15 @@ Resources :doc:`ValueSource ` A data source describing a single value. ==================================================================== ======================================================================== +Other +----- + +============================================== ======================================================================== + Component Description +============================================== ======================================================================== + :doc:`Constants ` Symbolic constants used by various APIs. +============================================== ======================================================================== + .. toctree:: :hidden: @@ -95,3 +105,4 @@ Resources containers/index resources/index widgets/index + constants diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index ef89fb7fef..c0e8abb91e 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -25,7 +25,7 @@ WebView,General Widget,:class:`~toga.WebView`,A panel for displaying HTML,|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 than the area of the container,|y|,|y|,|y|,|y|,|y|, -SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,Split Container,|b|,|b|,|b|,,, +SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,A container that divides an area into two panels with a movable border,|y|,|y|,|y|,,, 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|, Font,Resource,:class:`~toga.Font`,Fonts,|b|,|b|,|b|,|b|,|b|, diff --git a/docs/reference/images/SplitContainer.jpeg b/docs/reference/images/SplitContainer.jpeg deleted file mode 100644 index 091c207b84..0000000000 Binary files a/docs/reference/images/SplitContainer.jpeg and /dev/null differ diff --git a/docs/reference/images/SplitContainer.png b/docs/reference/images/SplitContainer.png new file mode 100644 index 0000000000..d45e981406 Binary files /dev/null and b/docs/reference/images/SplitContainer.png differ diff --git a/dummy/src/toga_dummy/widgets/scrollcontainer.py b/dummy/src/toga_dummy/widgets/scrollcontainer.py index 5944499d22..b93347fa1e 100644 --- a/dummy/src/toga_dummy/widgets/scrollcontainer.py +++ b/dummy/src/toga_dummy/widgets/scrollcontainer.py @@ -20,6 +20,7 @@ def get_height(self): def set_content(self, widget): self.scroll_container.content = widget self._action("set content", widget=widget) + self.scroll_container.content = widget def get_vertical(self): return self._get_value("vertical", True) diff --git a/dummy/src/toga_dummy/widgets/splitcontainer.py b/dummy/src/toga_dummy/widgets/splitcontainer.py index ee92ea0230..88690bb594 100644 --- a/dummy/src/toga_dummy/widgets/splitcontainer.py +++ b/dummy/src/toga_dummy/widgets/splitcontainer.py @@ -1,12 +1,23 @@ +from toga.constants import Direction + +from ..utils import not_required +from ..window import Container from .base import Widget +@not_required # Testbed coverage is complete for this widget. class SplitContainer(Widget): def create(self): self._action("create SplitContainer") + self._content = [] + + def set_content(self, content, flex): + self._action("set content", content=content, flex=flex) + for widget in content: + self._content.append(Container(widget)) - def add_content(self, position, widget, flex): - self._action("add content", position=position, widget=widget, flex=flex) + def get_direction(self): + return self._get_value("direction", Direction.VERTICAL) def set_direction(self, value): self._set_value("direction", value) diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index 2d714c0599..b9f51cc3ac 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -26,14 +26,14 @@ def content(self, value): @property def width(self): - return self._content.get_size()[0] + return self.content.get_size()[0] @property def height(self): - return self._content.get_size()[1] + return self.content.get_size()[1] def refreshed(self): - if self._content: + if self.content: self.content.refresh() diff --git a/examples/splitcontainer/CHANGELOG b/examples/splitcontainer/CHANGELOG new file mode 100644 index 0000000000..c6b095235f --- /dev/null +++ b/examples/splitcontainer/CHANGELOG @@ -0,0 +1 @@ +See Toga releases for change notes. diff --git a/examples/splitcontainer/LICENSE b/examples/splitcontainer/LICENSE new file mode 100644 index 0000000000..f09583bfab --- /dev/null +++ b/examples/splitcontainer/LICENSE @@ -0,0 +1 @@ +Released under the same license as Toga. See the root of the Toga repository for details. diff --git a/examples/splitcontainer/README.rst b/examples/splitcontainer/README.rst new file mode 100644 index 0000000000..edb8f53d43 --- /dev/null +++ b/examples/splitcontainer/README.rst @@ -0,0 +1,12 @@ +SplitContainer +============== + +Test app for the SplitContainer widget. The app provides various controls for the +content and properties of the container. + +Quickstart +~~~~~~~~~~ + +To run this example: + + $ python -m splitcontainer diff --git a/examples/splitcontainer/pyproject.toml b/examples/splitcontainer/pyproject.toml new file mode 100644 index 0000000000..6bf093271f --- /dev/null +++ b/examples/splitcontainer/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["briefcase"] + +[tool.briefcase] +project_name = "SplitContainer Demo" +bundle = "org.beeware" +version = "0.0.1" +url = "https://beeware.org" +license = "BSD license" +author = "Tiberius Yak" +author_email = "tiberius@beeware.org" + +[tool.briefcase.app.splitcontainer] +formal_name = "SplitContainer Demo" +description = "A testing app" +sources = ["splitcontainer"] +requires = [ + "../../core", +] + + +[tool.briefcase.app.splitcontainer.macOS] +requires = [ + "../../cocoa", + "std-nslog>=1.0.0", +] + +[tool.briefcase.app.splitcontainer.linux] +requires = [ + "../../gtk", +] + +[tool.briefcase.app.splitcontainer.windows] +requires = [ + "../../winforms", +] + +# Mobile deployments +[tool.briefcase.app.splitcontainer.iOS] +requires = [ + "../../iOS", + "std-nslog>=1.0.0", +] + +[tool.briefcase.app.splitcontainer.android] +requires = [ + "../../android", +] + +# Web deployment +[tool.briefcase.app.splitcontainer.web] +requires = [ + "../../web", +] +style_framework = "Shoelace v2.3" diff --git a/examples/splitcontainer/splitcontainer/__init__.py b/examples/splitcontainer/splitcontainer/__init__.py new file mode 100644 index 0000000000..86826e638c --- /dev/null +++ b/examples/splitcontainer/splitcontainer/__init__.py @@ -0,0 +1,9 @@ +# Examples of valid version strings +# __version__ = '1.2.3.dev1' # Development release 1 +# __version__ = '1.2.3a1' # Alpha Release 1 +# __version__ = '1.2.3b1' # Beta Release 1 +# __version__ = '1.2.3rc1' # RC Release 1 +# __version__ = '1.2.3' # Final Release +# __version__ = '1.2.3.post1' # Post Release 1 + +__version__ = "0.0.1" diff --git a/examples/splitcontainer/splitcontainer/__main__.py b/examples/splitcontainer/splitcontainer/__main__.py new file mode 100644 index 0000000000..c8307465f2 --- /dev/null +++ b/examples/splitcontainer/splitcontainer/__main__.py @@ -0,0 +1,4 @@ +from .app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/examples/splitcontainer/splitcontainer/app.py b/examples/splitcontainer/splitcontainer/app.py new file mode 100644 index 0000000000..541b1e7a4f --- /dev/null +++ b/examples/splitcontainer/splitcontainer/app.py @@ -0,0 +1,104 @@ +import toga +from toga.constants import CENTER, COLUMN, ROW, Direction +from toga.style import Pack + + +class ContentControls(toga.Box): + def __init__(self, split, index): + super().__init__(style=Pack(direction=ROW, flex=1)) + self.split = split + self.index = index + + self.sw_content = toga.Switch("Content", value=True, on_change=self.on_change) + self.sw_flexible = toga.Switch("Flexible", value=True, on_change=self.on_change) + self.add( + toga.Box(style=Pack(flex=1)), # Spacer + toga.Box( + style=Pack(direction=COLUMN), + children=[self.sw_content, self.sw_flexible], + ), + toga.Box(style=Pack(flex=1)), # Spacer + ) + self.on_change(None) + + def on_change(self, switch): + self.sw_flexible.enabled = self.sw_content.value + + if self.sw_content.value: + box = toga.Box(style=Pack(padding=10, background_color="cyan")) + if not self.sw_flexible.value: + box.style.update(width=100, height=100) + else: + box = None + + content = list(self.split.content) + content[self.index] = box + self.split.content = content + + +class SplitControls(toga.Box): + def __init__(self, split): + super().__init__(style=Pack(direction=COLUMN, alignment=CENTER, flex=1)) + self.split = split + + self.add( + toga.Box( + style=Pack(direction=ROW), + children=[ + toga.Button(25, on_press=self.on_position), + toga.Button(50, on_press=self.on_position), + toga.Button(75, on_press=self.on_position), + ], + ), + toga.Box( + style=Pack(direction=ROW), + children=[ + toga.Button("Direction", on_press=self.on_direction), + ], + ), + ) + + def on_position(self, button): + percent = int(button.text) + content = self.split.content + self.split.content = ((content[0], percent), (content[1], 100 - percent)) + + def on_direction(self, button): + self.split.direction = ( + Direction.HORIZONTAL + if self.split.direction == Direction.VERTICAL + else Direction.VERTICAL + ) + + +class SplitContainerApp(toga.App): + def startup(self): + self.split = toga.SplitContainer(style=Pack(padding=10)) + + main_box = toga.Box( + style=Pack(direction=COLUMN), + children=[ + toga.Box( + style=Pack(direction=ROW), + children=[ + ContentControls(self.split, 0), + SplitControls(self.split), + ContentControls(self.split, 1), + ], + ), + self.split, + ], + ) + + self.main_window = toga.MainWindow(self.name) + self.main_window.content = main_box + self.main_window.show() + + +def main(): + return SplitContainerApp("SplitContainer", "org.beeware.widgets.splitcontainer") + + +if __name__ == "__main__": + app = main() + app.main_loop() diff --git a/gtk/src/toga_gtk/widgets/base.py b/gtk/src/toga_gtk/widgets/base.py index 58765399e3..c495bbe4ae 100644 --- a/gtk/src/toga_gtk/widgets/base.py +++ b/gtk/src/toga_gtk/widgets/base.py @@ -68,7 +68,7 @@ def container(self, container): for child in self.interface.children: child._impl.container = container - self.rehint() + self.refresh() def get_enabled(self): return self.native.get_sensitive() diff --git a/gtk/src/toga_gtk/widgets/splitcontainer.py b/gtk/src/toga_gtk/widgets/splitcontainer.py index 6f8f271323..fc9b2c5314 100644 --- a/gtk/src/toga_gtk/widgets/splitcontainer.py +++ b/gtk/src/toga_gtk/widgets/splitcontainer.py @@ -1,67 +1,101 @@ +from travertino.size import at_least + from ..container import TogaContainer from ..libs import Gtk from .base import Widget -class TogaSplitContainer(Gtk.Paned): - def __init__(self, impl): - Gtk.Paned.__init__(self) - self._impl = impl - self.interface = self._impl.interface +class SplitContainer(Widget): + def create(self): + self.native = Gtk.Paned() + self.native.set_wide_handle(True) - def do_size_allocate(self, allocation): - Gtk.Paned.do_size_allocate(self, allocation) + self.sub_containers = [TogaContainer(), TogaContainer()] + self.native.pack1(self.sub_containers[0], True, False) + self.native.pack2(self.sub_containers[1], True, False) - # Turn all the weights into a fraction of 1.0 - total = sum(self.interface._weight) - self.interface._weight = [weight / total for weight in self.interface._weight] + self._split_proportion = 0.5 - # Set the position of splitter depending on the weight of splits. - self.set_position( - int( - self.interface._weight[0] * self.get_allocated_width() - if self.interface.direction == self.interface.VERTICAL - else self.get_allocated_height() - ) - ) + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) + # If we've got a pending split to apply, set the split position. + # However, only do this if the layout is more than the min size; + # there are initial 0-sized layouts for which the split is meaningless. + if ( + self._split_proportion + and width >= self.interface._MIN_WIDTH + and height > self.interface._MIN_HEIGHT + ): + if self.interface.direction == self.interface.VERTICAL: + position = int(self._split_proportion * width) + else: + position = int(self._split_proportion * height) -class SplitContainer(Widget): - def create(self): - self.native = TogaSplitContainer(self) - self.native.set_wide_handle(True) + self.native.set_position(position) + self._split_proportion = None + + def set_content(self, content, flex): + # Clear any existing content + for container in self.sub_containers: + container.content = None + + # Add all children to the content widget. + for position, widget in enumerate(content): + self.sub_containers[position].content = widget - # Use Paned widget rather than VPaned and HPaned deprecated widgets - # Note that orientation in toga behave unlike Gtk - if self.interface.direction == self.interface.VERTICAL: + # We now know the initial positions of the split. However, we can't *set* the + # because GTK requires a pixel position, and the widget isn't visible yet. So - + # store the split; and when we do our first layout, apply that proportion. + self._split_proportion = flex[0] / sum(flex) + + def get_direction(self): + if self.native.get_orientation() == Gtk.Orientation.HORIZONTAL: + return self.interface.VERTICAL + else: + return self.interface.HORIZONTAL + + def set_direction(self, value): + if value == self.interface.VERTICAL: self.native.set_orientation(Gtk.Orientation.HORIZONTAL) - elif self.interface.direction == self.interface.HORIZONTAL: - self.native.set_orientation(Gtk.Orientation.VERTICAL) else: - raise ValueError("Allowed orientation is VERTICAL or HORIZONTAL") + self.native.set_orientation(Gtk.Orientation.VERTICAL) - def add_content(self, position, widget, flex): - # Add all children to the content widget. - sub_container = TogaContainer() - sub_container.content = widget + def rehint(self): + # This is a SWAG (scientific wild-ass guess). There doesn't appear to be + # an actual API to get the true size of the splitter. 10px seems enough. + SPLITTER_WIDTH = 10 + if self.interface.direction == self.interface.HORIZONTAL: + # When the splitter is horizontal, the splitcontainer must be + # at least as wide as it's widest sub-container, and at least + # as tall as the minimum height of all subcontainers, plus the + # height of the splitter itself. Enforce a minimum size in both + # axies + min_width = self.interface._MIN_WIDTH + min_height = 0 + for sub_container in self.sub_containers: + # Make sure the subcontainer's size is up to date + sub_container.recompute() - if position >= 2: - raise ValueError("SplitContainer content must be a 2-tuple") + min_width = max(min_width, sub_container.min_width) + min_height += sub_container.min_height - if position == 0: - self.native.pack1(sub_container, flex, False) - elif position == 1: - self.native.pack2(sub_container, flex, False) + min_height = max(min_height, self.interface._MIN_HEIGHT) + SPLITTER_WIDTH + else: + # When the splitter is vertical, the splitcontainer must be + # at least as tall as it's tallest sub-container, and at least + # as wide as the minimum width of all subcontainers, plus the + # width of the splitter itself. + min_width = 0 + min_height = self.interface._MIN_HEIGHT + for sub_container in self.sub_containers: + # Make sure the subcontainer's size is up to date + sub_container.recompute() - def set_app(self, app): - if self.interface.content: - self.interface.content[0].app = self.interface.app - self.interface.content[1].app = self.interface.app + min_width += sub_container.min_width + min_height = max(min_height, sub_container.min_height) - def set_window(self, window): - if self.interface.content: - self.interface.content[0].window = self.interface.window - self.interface.content[1].window = self.interface.window + min_width = max(min_width, self.interface._MIN_WIDTH) + SPLITTER_WIDTH - def set_direction(self, value): - self.interface.factory.not_implemented("SplitContainer.set_direction()") + self.interface.intrinsic.width = at_least(min_width) + self.interface.intrinsic.height = at_least(min_height) diff --git a/gtk/tests_backend/widgets/splitcontainer.py b/gtk/tests_backend/widgets/splitcontainer.py new file mode 100644 index 0000000000..597a0941c1 --- /dev/null +++ b/gtk/tests_backend/widgets/splitcontainer.py @@ -0,0 +1,30 @@ +import asyncio + +from toga_gtk.libs import Gtk + +from .base import SimpleProbe + + +class SplitContainerProbe(SimpleProbe): + native_class = Gtk.Paned + border_size = 0 + direction_change_preserves_position = False + + def move_split(self, position): + self.native.set_position(position) + + def repaint_needed(self): + return ( + self.impl.sub_containers[0].needs_redraw + or self.impl.sub_containers[1].needs_redraw + or super().repaint_needed() + ) + + async def wait_for_split(self): + sub1 = self.impl.sub_containers[0] + position = sub1.get_allocated_height(), sub1.get_allocated_width() + current = None + while position != current: + position = current + await asyncio.sleep(0.05) + current = sub1.get_allocated_height(), sub1.get_allocated_width() diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 66a4b533f2..1c9d38cb00 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -14,12 +14,22 @@ register_assert_rewrite("tests_backend") +# Use this for widgets or tests which are not supported on some platforms, but could be +# supported in the future. def skip_on_platforms(*platforms): current_platform = toga.platform.current_platform if current_platform in platforms: skip(f"not yet implemented on {current_platform}") +# Use this for widgets or tests which are not supported on some platforms, and will not +# be supported in the foreseeable future. +def xfail_on_platforms(*platforms): + current_platform = toga.platform.current_platform + if current_platform in platforms: + skip(f"not applicable on {current_platform}") + + @fixture(scope="session") def app(): return toga.App.app diff --git a/testbed/tests/widgets/test_divider.py b/testbed/tests/widgets/test_divider.py index fcac2aa442..bf3dc3c41e 100644 --- a/testbed/tests/widgets/test_divider.py +++ b/testbed/tests/widgets/test_divider.py @@ -1,6 +1,7 @@ import pytest import toga +from toga.constants import Direction from toga.style.pack import COLUMN, ROW from ..conftest import skip_on_platforms @@ -21,7 +22,7 @@ async def test_directions(widget, probe): # Widget should be initially horizontal. # Container is initially a row box, so the divider will be # both narrow and short - assert widget.direction == toga.Divider.HORIZONTAL + assert widget.direction == Direction.HORIZONTAL assert probe.height < 10 assert probe.width < 10 @@ -30,16 +31,16 @@ async def test_directions(widget, probe): await probe.redraw("Divider should become wide") # The divider will now be wide, but short. - assert widget.direction == toga.Divider.HORIZONTAL + assert widget.direction == Direction.HORIZONTAL assert probe.height < 10 assert probe.width > 100 # Make the divider vertical - widget.direction = toga.Divider.VERTICAL + widget.direction = Direction.VERTICAL await probe.redraw("Divider should be VERTICAL") # In a column box, a vertical divider will be narrow and short. - assert widget.direction == toga.Divider.VERTICAL + assert widget.direction == Direction.VERTICAL assert probe.height < 10 assert probe.width < 10 @@ -48,6 +49,24 @@ async def test_directions(widget, probe): await probe.redraw("Divider should become tall") # In a row box, a vertical divider will be narrow and tall. - assert widget.direction == toga.Divider.VERTICAL + assert widget.direction == Direction.VERTICAL assert probe.height > 100 assert probe.width < 10 + + # Make the divider horizontal + widget.direction = Direction.HORIZONTAL + await probe.redraw("Divider should be HORIZONTAL") + + # In a row box, a horizontal divider will be narrow and short. + assert widget.direction == Direction.HORIZONTAL + assert probe.height < 10 + assert probe.width < 10 + + # Make the container a COLUMN box again + widget.parent.style.direction = COLUMN + await probe.redraw("Divider should become wide") + + # In a column box, a horizontal divider will be narrow and short. + assert widget.direction == Direction.HORIZONTAL + assert probe.height < 10 + assert probe.width > 100 diff --git a/testbed/tests/widgets/test_splitcontainer.py b/testbed/tests/widgets/test_splitcontainer.py new file mode 100644 index 0000000000..69aad102b1 --- /dev/null +++ b/testbed/tests/widgets/test_splitcontainer.py @@ -0,0 +1,181 @@ +import pytest +from pytest import approx + +import toga +from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE +from toga.constants import Direction +from toga.style.pack import Pack + +from ..conftest import xfail_on_platforms +from .probe import get_probe +from .properties import ( # noqa: F401 + test_enable_noop, + test_flex_widget_size, + test_focus_noop, +) + + +@pytest.fixture +async def content1(): + return toga.Box( + children=[toga.Label("Box 1 content", style=Pack(flex=1))], + style=Pack(background_color=REBECCAPURPLE), + ) + + +@pytest.fixture +async def content2(): + return toga.Box( + children=[toga.Label("Box 2 content", style=Pack(flex=1))], + style=Pack(background_color=CORNFLOWERBLUE), + ) + + +@pytest.fixture +async def content3(): + return toga.Box( + children=[toga.Label("Box 3 content", style=Pack(flex=1))], + style=Pack(background_color=GOLDENROD), + ) + + +@pytest.fixture +async def content1_probe(content1): + return get_probe(content1) + + +@pytest.fixture +async def content2_probe(content2): + return get_probe(content2) + + +@pytest.fixture +async def content3_probe(content3): + return get_probe(content3) + + +@pytest.fixture +async def widget(content1, content2): + xfail_on_platforms("android", "iOS") + return toga.SplitContainer(content=[content1, content2], style=Pack(flex=1)) + + +async def test_set_content( + widget, + probe, + content1_probe, + content2, + content2_probe, + content3, + content3_probe, +): + """Splitview content can be changed""" + # Both widgets are initially within 20px of an even split + assert content1_probe.width == pytest.approx(probe.width / 2, abs=20) + assert content2_probe.width == pytest.approx(probe.width / 2, abs=20) + + # Move content2 to the first panel, replace the second panel with content3 + # and apply an uneven content split. + widget.content = [(content2, 2), (content3, 3)] + await probe.wait_for_split() + await probe.redraw("Content should have a 40:60 split") + assert content2_probe.width == pytest.approx(probe.width * 2 / 5, abs=20) + assert content3_probe.width == pytest.approx(probe.width * 3 / 5, abs=20) + + # Clear content2, but keep the split proportion. + widget.content = [(None, 2), (content3, 3)] + await probe.wait_for_split() + await probe.redraw("Content should have a 40:60 split, but only right content") + assert content3_probe.width == pytest.approx(probe.width * 3 / 5, abs=20) + + # Bring back content2, and drop content 3 + widget.content = [content2, None] + await probe.wait_for_split() + await probe.redraw("Content should have a 50:50 split, but only left content") + assert content2_probe.width == pytest.approx(probe.width / 2, abs=20) + + +async def test_set_direction( + widget, + probe, + content1, + content1_probe, + content2, + content2_probe, +): + """Splitview direction can be changed""" + two_borders = probe.border_size * 2 + + def assert_full_width(): + expected = approx(probe.width - two_borders, abs=1) + assert content1_probe.width == expected + assert content2_probe.width == expected + + def assert_full_height(): + expected = approx(probe.height - two_borders, abs=1) + assert content1_probe.height == expected + assert content2_probe.height == expected + + def assert_split_width(flex1, flex2): + total = flex1 + flex2 + assert content1_probe.width == approx(probe.width * flex1 / total, abs=20) + assert content2_probe.width == approx(probe.width * flex2 / total, abs=20) + + def assert_split_height(flex1, flex2): + total = flex1 + flex2 + assert content1_probe.height == approx(probe.height * flex1 / total, abs=20) + assert content2_probe.height == approx(probe.height * flex2 / total, abs=20) + + assert_full_height() + assert_split_width(1, 1) + + widget.direction = Direction.HORIZONTAL + await probe.wait_for_split() + await probe.redraw("Split should now be horizontal") + + assert_full_width() + if probe.direction_change_preserves_position: + assert_split_height(1, 1) + + widget.content = [(content1, 3), (content2, 1)] + await probe.wait_for_split() + await probe.redraw("Split should be a horizontal 75:25 split") + + assert_full_width() + assert_split_height(3, 1) + + widget.direction = Direction.VERTICAL + await probe.wait_for_split() + await probe.redraw("Split should now be vertical again") + + assert_full_height() + if probe.direction_change_preserves_position: + assert_split_width(3, 1) + + +async def test_move_splitter( + widget, + probe, + content1_probe, + content2_probe, +): + """Splitview position can be manually changed""" + # Both widgets are initially within 20px of an even split horizontally + assert content1_probe.width == pytest.approx(probe.width / 2, abs=20) + assert content2_probe.width == pytest.approx(probe.width / 2, abs=20) + + probe.move_split(400) + await probe.wait_for_split() + await probe.redraw("Split has been moved right") + + # Content1 is now 400 pixels wide. + assert content1_probe.width == pytest.approx(400, abs=20) + assert content2_probe.width == pytest.approx(probe.width - 400, abs=20) + + # Try to move the splitter to the very end. + probe.move_split(probe.width) + await probe.wait_for_split() + await probe.redraw("Split has been moved past the minimum size limit") + + # Content2 is not 0 sized. + assert content2_probe.width > 50 diff --git a/winforms/src/toga_winforms/container.py b/winforms/src/toga_winforms/container.py index e8b8be1761..fb68c8247f 100644 --- a/winforms/src/toga_winforms/container.py +++ b/winforms/src/toga_winforms/container.py @@ -2,23 +2,22 @@ class BaseContainer: - def init_container(self, native_parent): + def __init__(self, native_parent): + self.native_parent = 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_container(self, native_parent): - super().init_container(native_parent) + def __init__(self, native_parent): + super().__init__(native_parent) + self.content = None self.native_content = WinForms.Panel() native_parent.Controls.Add(self.native_content) @@ -26,19 +25,23 @@ def set_content(self, widget): self.clear_content() if widget: widget.container = self + self.content = widget def clear_content(self): - if self.interface.content: - self.interface.content._impl.container = None + if self.content: + self.content.container = None + self.content = None - def resize_content(self, width, height): + def resize_content(self, width, height, *, force_refresh=False): if (self.width, self.height) != (width, height): self.width, self.height = (width, height) - if self.interface.content: - self.interface.content.refresh() + force_refresh = True + + if force_refresh and self.content: + self.content.interface.refresh() def refreshed(self): - layout = self.interface.content.layout + layout = self.content.interface.layout self.native_content.Size = Size( max(self.width, layout.width), max(self.height, layout.height), diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index da6a93cf99..8ac0e902f8 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -11,7 +11,6 @@ class Widget: _background_supports_alpha = True def __init__(self, interface): - super().__init__() self.interface = interface self.interface._impl = self diff --git a/winforms/src/toga_winforms/widgets/scrollcontainer.py b/winforms/src/toga_winforms/widgets/scrollcontainer.py index b91b39f257..40cb091349 100644 --- a/winforms/src/toga_winforms/widgets/scrollcontainer.py +++ b/winforms/src/toga_winforms/widgets/scrollcontainer.py @@ -28,7 +28,7 @@ class ScrollContainer(Widget, Container): def create(self): self.native = Panel() self.native.AutoScroll = True - self.init_container(self.native) + Container.__init__(self, 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 diff --git a/winforms/src/toga_winforms/widgets/slider.py b/winforms/src/toga_winforms/widgets/slider.py index 0220d4dfb9..dcc6faf3a9 100644 --- a/winforms/src/toga_winforms/widgets/slider.py +++ b/winforms/src/toga_winforms/widgets/slider.py @@ -1,6 +1,6 @@ from travertino.size import at_least -import toga +from toga.widgets.slider import IntSliderImpl from toga_winforms.libs import WinForms from .base import Widget @@ -17,8 +17,9 @@ BOTTOM_RIGHT_TICK_STYLE = WinForms.TickStyle.BottomRight -class Slider(Widget, toga.widgets.slider.IntSliderImpl): +class Slider(Widget, IntSliderImpl): def create(self): + IntSliderImpl.__init__(self) self.native = WinForms.TrackBar() self.native.AutoSize = False diff --git a/winforms/src/toga_winforms/widgets/splitcontainer.py b/winforms/src/toga_winforms/widgets/splitcontainer.py index d79ed79622..bc098dbb9a 100644 --- a/winforms/src/toga_winforms/widgets/splitcontainer.py +++ b/winforms/src/toga_winforms/widgets/splitcontainer.py @@ -1,69 +1,84 @@ -from toga_winforms.container import Container -from toga_winforms.libs import WinForms +from System.Windows.Forms import ( + BorderStyle, + Orientation, + SplitContainer as NativeSplitContainer, +) +from toga.constants import Direction + +from ..container import Container from .base import Widget +class SplitPanel(Container): + def resize_content(self, **kwargs): + size = self.native_parent.ClientSize + super().resize_content(size.Width, size.Height, **kwargs) + + class SplitContainer(Widget): def create(self): - self.native = WinForms.SplitContainer() - self.native.interface = self.interface - self.native.Resize += self.winforms_resize - self.native.SplitterMoved += self.winforms_resize - self.ratio = None - - def add_content(self, position, widget, flex): - # TODO: Add flex option to the implementation - widget.frame = self - - # Add all children to the content widget. - for child in widget.interface.children: - child._impl.container = widget - - if position >= 2: - raise ValueError("SplitContainer content must be a 2-tuple") - - if position == 0: - self.native.Panel1.Controls.Add(widget.native) - widget.viewport = Container(self.native.Panel1) - - elif position == 1: - self.native.Panel2.Controls.Add(widget.native) - widget.viewport = Container(self.native.Panel2) - - # Turn all the weights into a fraction of 1.0 - total = sum(self.interface._weight) - self.interface._weight = [ - weight / total for weight in self.interface._weight - ] - - # Set the position of splitter depending on the weight of splits. - total_distance = ( - self.native.Width - if self.interface.direction == self.interface.VERTICAL - else self.native.Height - ) - self.native.SplitterDistance = int( - self.interface._weight[0] * total_distance - ) - - def set_app(self, app): - if self.interface.content: - for content in self.interface.content: - content.app = self.interface.app - - def set_window(self, window): - if self.interface.content: - for content in self.interface.content: - content.window = self.interface.window + self.native = NativeSplitContainer() + self.native.SplitterMoved += lambda sender, event: self.resize_content() + + # Despite what the BorderStyle documentation says, there is no border by default + # (at least on Windows 10), which would make the split bar invisible. + self.native.BorderStyle = BorderStyle.Fixed3D + + self.panels = (SplitPanel(self.native.Panel1), SplitPanel(self.native.Panel2)) + self.pending_position = None + + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) + + force_refresh = False + if self.pending_position: + self.set_position(self.pending_position) + self.pending_position = None + force_refresh = True # Content has changed + + self.resize_content(force_refresh=force_refresh) + + def set_content(self, content, flex): + # In case content moves from one panel to another, make sure it's removed first + # so it doesn't get removed again by set_content. + for panel in self.panels: + panel.clear_content() + + for panel, widget in zip(self.panels, content): + panel.set_content(widget) + + self.pending_position = flex[0] / sum(flex) + + def get_direction(self): + return { + Orientation.Vertical: Direction.VERTICAL, + Orientation.Horizontal: Direction.HORIZONTAL, + }[self.native.Orientation] def set_direction(self, value): - self.native.Orientation = ( - WinForms.Orientation.Vertical if value else WinForms.Orientation.Horizontal + position = self.get_position() + + self.native.Orientation = { + Direction.VERTICAL: Orientation.Vertical, + Direction.HORIZONTAL: Orientation.Horizontal, + }[value] + + self.set_position(position) + + def get_position(self): + return self.native.SplitterDistance / self.get_max_position() + + def set_position(self, position): + self.native.SplitterDistance = round(position * self.get_max_position()) + + def get_max_position(self): + return ( + self.native.Width + if self.get_direction() == Direction.VERTICAL + else self.native.Height ) - def winforms_resize(self, sender, args): - if self.interface.content: - # Re-layout the content - for content in self.interface.content: - content.refresh() + def resize_content(self, **kwargs): + for panel in self.panels: + panel.resize_content(**kwargs) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index f9a350cb0b..422a1a4df8 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 - self.init_container(self.native) + super().__init__(self.native) self.native.MinimizeBox = self.native.interface.minimizable diff --git a/winforms/tests_backend/widgets/splitcontainer.py b/winforms/tests_backend/widgets/splitcontainer.py new file mode 100644 index 0000000000..e62424e731 --- /dev/null +++ b/winforms/tests_backend/widgets/splitcontainer.py @@ -0,0 +1,22 @@ +from System.Windows.Forms import Panel, SplitContainer + +from .base import SimpleProbe + + +class SplitContainerProbe(SimpleProbe): + native_class = SplitContainer + border_size = 2 + direction_change_preserves_position = True + + def __init__(self, widget): + super().__init__(widget) + + for panel in [self.native.Panel1, self.native.Panel2]: + assert panel.Controls.Count == 1 + assert isinstance(panel.Controls[0], Panel) + + def move_split(self, position): + self.native.SplitterDistance = round(position * self.scale_factor) + + async def wait_for_split(self): + pass