diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index b335e2b56f..360c0aa5d2 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from toga.constants import CENTER, JUSTIFY, LEFT, RIGHT, TRANSPARENT from ..colors import native_color @@ -16,7 +18,8 @@ def _get_activity(_cache=[]): return _cache[0] # See MainActivity.onCreate() for initialization of .singletonThis: # https://github.com/beeware/briefcase-android-gradle-template/blob/3.7/%7B%7B%20cookiecutter.formal_name%20%7D%7D/app/src/main/java/org/beeware/android/MainActivity.java - if not MainActivity.singletonThis: + # This can't be tested because if it isn't set, nothing else will work. + if not MainActivity.singletonThis: # pragma: no cover raise ValueError( "Unable to find MainActivity.singletonThis from Python. This is typically set by " "org.beeware.android.MainActivity.onCreate()." @@ -39,8 +42,9 @@ def __init__(self, interface): # they have been added to a container. self.interface.style.reapply() + @abstractmethod def create(self): - pass + ... def set_app(self, app): pass @@ -55,20 +59,18 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # container is set to None, removing self from the container.native - self._container.native.removeView(self.native) - self._container.native.invalidate() - self._container = None + assert container is None, "Widget already has a container" + + # container is set to None, removing self from the container.native + self._container.native.removeView(self.native) + self._container.native.invalidate() + self._container = None elif container: self._container = container - if self.native: - # When initially setting the container and adding widgets to the container, - # we provide no `LayoutParams`. Those are promptly added when Toga - # calls `widget.rehint()` and `widget.set_bounds()`. - self._container.native.addView(self.native) + # When initially setting the container and adding widgets to the container, + # we provide no `LayoutParams`. Those are promptly added when Toga + # calls `widget.rehint()` and `widget.set_bounds()`. + self._container.native.addView(self.native) for child in self.interface.children: child._impl.container = container @@ -85,10 +87,10 @@ def focus(self): self.native.requestFocus() def get_tab_index(self): - self.interface.factory.not_implementated("Widget.get_tab_index()") + self.interface.factory.not_implemented("Widget.get_tab_index()") def set_tab_index(self, tab_index): - self.interface.factory.not_implementated("Widget.set_tab_index()") + self.interface.factory.not_implemented("Widget.set_tab_index()") # APPLICATOR @@ -98,11 +100,6 @@ def set_bounds(self, x, y, width, height): self.container.set_child_bounds(self, x, y, width, height) def set_hidden(self, hidden): - view = View(self._native_activity) - if not view.getClass().isInstance(self.native): - # save guard for Widgets like Canvas that are not based on View - self.interface.factory.not_implemented("Widget.set_hidden()") - return if hidden: self.native.setVisibility(View.INVISIBLE) else: @@ -149,6 +146,7 @@ def remove_child(self, child): def refresh(self): self.rehint() + @abstractmethod def rehint(self): pass diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index c75fb8a44d..81e983bdf6 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -31,6 +31,9 @@ def create(self): ) self.native.setDrawHandler(DrawHandler(self.interface)) + def set_hidden(self, hidden): + self.interface.factory.not_implemented("Canvas.set_hidden()") + def redraw(self): pass diff --git a/android/src/toga_android/widgets/multilinetextinput.py b/android/src/toga_android/widgets/multilinetextinput.py index 53a46ac9ef..7a71e58fd7 100644 --- a/android/src/toga_android/widgets/multilinetextinput.py +++ b/android/src/toga_android/widgets/multilinetextinput.py @@ -60,8 +60,8 @@ def set_on_change(self, handler): self.native.addTextChangedListener(self._textChangedListener) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def scroll_to_bottom(self): last_line = (self.native.getLineCount() - 1) * self.native.getLineHeight() diff --git a/android/src/toga_android/widgets/textinput.py b/android/src/toga_android/widgets/textinput.py index 4a21327708..b9b7934663 100644 --- a/android/src/toga_android/widgets/textinput.py +++ b/android/src/toga_android/widgets/textinput.py @@ -68,7 +68,7 @@ def is_valid(self): self.interface.factory.not_implemented("TextInput.is_valid()") def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) # Refuse to call measure() if widget has no container, i.e., has no LayoutParams. # On Android, EditText's measure() throws NullPointerException if the widget has no # LayoutParams. diff --git a/android/src/toga_android/widgets/webview.py b/android/src/toga_android/widgets/webview.py index bf6f990725..c33b9a59a1 100644 --- a/android/src/toga_android/widgets/webview.py +++ b/android/src/toga_android/widgets/webview.py @@ -80,7 +80,7 @@ def set_alignment(self, value): self.native.setGravity(Gravity.CENTER_VERTICAL | align(value)) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) # Refuse to call measure() if widget has no container, i.e., has no LayoutParams. # Android's measure() throws NullPointerException if the widget has no LayoutParams. if not self.native.getLayoutParams(): diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index 4d6767988d..cc561f656e 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -1,8 +1,9 @@ import asyncio from java import dynamic_proxy +from pytest import approx -from android.view import ViewTreeObserver +from android.view import View, ViewTreeObserver from toga.fonts import SYSTEM from .properties import toga_color @@ -49,6 +50,10 @@ def assert_container(self, container): else: raise AssertionError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.getParent() is None + def assert_alignment(self, expected): assert self.alignment == expected @@ -92,9 +97,33 @@ def assert_height(self, min_height, max_height): min_height <= self.height <= max_height ), f"Height ({self.height}) not in range ({min_height}, {max_height})" + def assert_layout(self, size, position): + # Widget is contained + assert self.widget._impl.container is not None + assert self.native.getParent() is not None + + # Size and position is as expected. Values must be scaled from DP, and + # compared inexactly due to pixel scaling + assert ( + approx(self.native.getWidth() / self.scale_factor, rel=0.01), + approx(self.native.getHeight() / self.scale_factor, rel=0.01), + ) == size + assert ( + approx(self.native.getLeft() / self.scale_factor, rel=0.01), + approx(self.native.getTop() / self.scale_factor, rel=0.01), + ) == position + @property def background_color(self): return toga_color(self.native.getBackground().getColor()) async def press(self): self.native.performClick() + + @property + def is_hidden(self): + return self.native.getVisibility() == View.INVISIBLE + + @property + def has_focus(self): + return self.widget.app._impl.native.getCurrentFocus() == self.native diff --git a/android/tests_backend/widgets/textinput.py b/android/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..8e3b62c969 --- /dev/null +++ b/android/tests_backend/widgets/textinput.py @@ -0,0 +1,8 @@ +from java import jclass + +from .label import LabelProbe + + +# On Android, a TextInput is just an editable TextView +class TextInputProbe(LabelProbe): + native_class = jclass("android.widget.EditText") diff --git a/changes/1718.bugfix.rst b/changes/1718.bugfix.rst new file mode 100644 index 0000000000..7468ca785b --- /dev/null +++ b/changes/1718.bugfix.rst @@ -0,0 +1 @@ +The usage of the deprecated ``set_wmclass`` API by the GTK backend has been removed. diff --git a/changes/1844.feature.rst b/changes/1834.feature.rst similarity index 100% rename from changes/1844.feature.rst rename to changes/1834.feature.rst diff --git a/cocoa/src/toga_cocoa/constraints.py b/cocoa/src/toga_cocoa/constraints.py index dcf5b2ac0f..402ac4070f 100644 --- a/cocoa/src/toga_cocoa/constraints.py +++ b/cocoa/src/toga_cocoa/constraints.py @@ -11,9 +11,10 @@ class Constraints: def __init__(self, widget): """ + A wrapper object storing the constraints required to position a widget + at a precise location in its container. - Args: - widget (:class: toga-cocoa.Widget): The widget that should be constrained. + :param widget: The Widget implementation to be constrained. """ self.widget = widget self._container = None @@ -24,23 +25,38 @@ def __init__(self, widget): self.left_constraint = None self.top_constraint = None + # Deletion isn't an event we can programatically invoke; deletion + # of constraints can take several iterations before it occurs. + def __del__(self): # pragma: nocover + self._remove_constraints() + + def _remove_constraints(self): + if self.container: + # print(f"Remove constraints for {self.widget} in {self.container}") + self.container.native.removeConstraint(self.width_constraint) + self.container.native.removeConstraint(self.height_constraint) + self.container.native.removeConstraint(self.left_constraint) + self.container.native.removeConstraint(self.top_constraint) + + self.width_constraint.release() + self.height_constraint.release() + self.left_constraint.release() + self.top_constraint.release() + @property def container(self): return self._container @container.setter def container(self, value): - if value is None and self.container: - # print("Remove constraints for", self.widget, 'in', self.container) - self.container.native.removeConstraint(self.width_constraint) - self.container.native.removeConstraint(self.height_constraint) - self.container.native.removeConstraint(self.left_constraint) - self.container.native.removeConstraint(self.top_constraint) - self._container = value - else: - self._container = value - # print("Add constraints for", self.widget, 'in', self.container, self.widget.interface.layout) - self.left_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # NOQA:E501 + # This will *always* remove and then add constraints. It relies on the base widget to + # *not* invoke this setter unless the container is actually changing. + + self._remove_constraints() + self._container = value + if value is not None: + # print(f"Add constraints for {self.widget} in {self.container} {self.widget.interface.layout}) + self.left_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeLeft, NSLayoutRelationEqual, @@ -48,10 +64,10 @@ def container(self, value): NSLayoutAttributeLeft, 1.0, 10, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.left_constraint) - self.top_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # NOQA:E501 + self.top_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeTop, NSLayoutRelationEqual, @@ -59,10 +75,10 @@ def container(self, value): NSLayoutAttributeTop, 1.0, 5, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.top_constraint) - self.width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # NOQA:E501 + self.width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeRight, NSLayoutRelationEqual, @@ -70,10 +86,10 @@ def container(self, value): NSLayoutAttributeLeft, 1.0, 50, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.width_constraint) - self.height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # NOQA:E501 + self.height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeBottom, NSLayoutRelationEqual, @@ -81,12 +97,12 @@ def container(self, value): NSLayoutAttributeTop, 1.0, 30, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.height_constraint) def update(self, x, y, width, height): if self.container: - # print("UPDATE", self.widget, 'in', self.container, 'to', x, y, width, height) + # print(f"UPDATE CONSTRAINTS {self.widget} in {self.container} {width}x{height}@{x},{y}") self.left_constraint.constant = x self.top_constraint.constant = y diff --git a/cocoa/src/toga_cocoa/widgets/activityindicator.py b/cocoa/src/toga_cocoa/widgets/activityindicator.py index 41a76b7c09..7baff70dfe 100644 --- a/cocoa/src/toga_cocoa/widgets/activityindicator.py +++ b/cocoa/src/toga_cocoa/widgets/activityindicator.py @@ -21,10 +21,6 @@ def create(self): # Add the layout constraints self.add_constraints() - def get_enabled(self): - # An activity indicator is always enabled - return True - def is_running(self): return self._is_running diff --git a/cocoa/src/toga_cocoa/widgets/base.py b/cocoa/src/toga_cocoa/widgets/base.py index 7260178940..b4b057f336 100644 --- a/cocoa/src/toga_cocoa/widgets/base.py +++ b/cocoa/src/toga_cocoa/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from toga.colors import TRANSPARENT from toga_cocoa.colors import native_color from toga_cocoa.constraints import Constraints @@ -15,8 +17,9 @@ def __init__(self, interface): self.create() self.interface.style.reapply() + @abstractmethod def create(self): - raise NotImplementedError() + ... def set_app(self, app): pass @@ -31,13 +34,12 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # existing container should be removed - self.constraints.container = None - self._container = None - self.native.removeFromSuperview() + assert container is None, "Widget already has a container" + + # Existing container should be removed + self.constraints.container = None + self._container = None + self.native.removeFromSuperview() elif container: # setting container self._container = container @@ -73,8 +75,7 @@ def set_alignment(self, alignment): pass def set_hidden(self, hidden): - if self.native: - self.native.setHidden(hidden) + self.native.setHidden(hidden) def set_font(self, font): pass @@ -93,10 +94,10 @@ def focus(self): self.interface.window._impl.native.makeFirstResponder(self.native) def get_tab_index(self): - self.interface.factory.not_implementated("Widget.get_tab_index()") + self.interface.factory.not_implemented("Widget.get_tab_index()") def set_tab_index(self, tab_index): - self.interface.factory.not_implementated("Widget.set_tab_index()") + self.interface.factory.not_implemented("Widget.set_tab_index()") # INTERFACE @@ -120,5 +121,6 @@ def add_constraints(self): def refresh(self): self.rehint() + @abstractmethod def rehint(self): - pass + ... diff --git a/cocoa/src/toga_cocoa/widgets/box.py b/cocoa/src/toga_cocoa/widgets/box.py index 6b8c8451d0..df5ebec834 100644 --- a/cocoa/src/toga_cocoa/widgets/box.py +++ b/cocoa/src/toga_cocoa/widgets/box.py @@ -20,10 +20,6 @@ def create(self): # Add the layout constraints self.add_constraints() - def get_enabled(self): - # A box is always enabled - return True - def rehint(self): content_size = self.native.intrinsicContentSize() self.interface.intrinsic.width = at_least(content_size.width) diff --git a/cocoa/src/toga_cocoa/widgets/detailedlist.py b/cocoa/src/toga_cocoa/widgets/detailedlist.py index 78bf043843..0f3105f8e1 100644 --- a/cocoa/src/toga_cocoa/widgets/detailedlist.py +++ b/cocoa/src/toga_cocoa/widgets/detailedlist.py @@ -182,5 +182,5 @@ def scroll_to_row(self, row): self.detailedlist.scrollRowToVisible(row) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/src/toga_cocoa/widgets/divider.py b/cocoa/src/toga_cocoa/widgets/divider.py index c495731031..0d983b9198 100644 --- a/cocoa/src/toga_cocoa/widgets/divider.py +++ b/cocoa/src/toga_cocoa/widgets/divider.py @@ -16,10 +16,6 @@ def create(self): # Set the initial direction self._direction = self.interface.HORIZONTAL - def get_enabled(self): - # A Divider is always enabled - return True - def rehint(self): content_size = self.native.intrinsicContentSize() diff --git a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py index 9323a97c41..010fc4527d 100644 --- a/cocoa/src/toga_cocoa/widgets/multilinetextinput.py +++ b/cocoa/src/toga_cocoa/widgets/multilinetextinput.py @@ -68,8 +68,8 @@ def set_font(self, font): self.text.font = font._impl.native def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def set_on_change(self, handler): self.interface.factory.not_implemented("MultilineTextInput.set_on_change()") diff --git a/cocoa/src/toga_cocoa/widgets/numberinput.py b/cocoa/src/toga_cocoa/widgets/numberinput.py index 90064e1b33..c6d5474056 100644 --- a/cocoa/src/toga_cocoa/widgets/numberinput.py +++ b/cocoa/src/toga_cocoa/widgets/numberinput.py @@ -214,7 +214,7 @@ def rehint(self): stepper_size = self.input.intrinsicContentSize() input_size = self.input.intrinsicContentSize() - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = max(input_size.height, stepper_size.height) def set_on_change(self, handler): diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index 7b2c264d01..c3951b4604 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -1,3 +1,5 @@ +from travertino.size import at_least + from toga_cocoa.libs import NSObject, NSTabView, NSTabViewItem, objc_method from toga_cocoa.window import CocoaViewport @@ -112,3 +114,7 @@ def get_current_tab_index(self): def set_current_tab_index(self, current_tab_index): self.native.selectTabViewItemAtIndex(current_tab_index) + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py index 198a0d08d0..2ab06edf87 100644 --- a/cocoa/src/toga_cocoa/widgets/scrollcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/scrollcontainer.py @@ -56,8 +56,8 @@ def set_horizontal(self, value): self.interface.refresh() def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def set_on_scroll(self, on_scroll): self.interface.factory.not_implemented("ScrollContainer.set_on_scroll()") diff --git a/cocoa/src/toga_cocoa/widgets/selection.py b/cocoa/src/toga_cocoa/widgets/selection.py index 71a11a10f2..9081f40c8c 100644 --- a/cocoa/src/toga_cocoa/widgets/selection.py +++ b/cocoa/src/toga_cocoa/widgets/selection.py @@ -30,7 +30,7 @@ def rehint(self): content_size = self.native.intrinsicContentSize() self.interface.intrinsic.height = content_size.height self.interface.intrinsic.width = at_least( - max(self.interface.MIN_WIDTH, content_size.width) + max(self.interface._MIN_WIDTH, content_size.width) ) def remove_all_items(self): diff --git a/cocoa/src/toga_cocoa/widgets/splitcontainer.py b/cocoa/src/toga_cocoa/widgets/splitcontainer.py index 0003616f35..9410925132 100644 --- a/cocoa/src/toga_cocoa/widgets/splitcontainer.py +++ b/cocoa/src/toga_cocoa/widgets/splitcontainer.py @@ -1,3 +1,5 @@ +from travertino.size import at_least + from toga_cocoa.libs import NSObject, NSSize, NSSplitView, objc_method from toga_cocoa.window import CocoaViewport @@ -79,3 +81,7 @@ def set_direction(self, value): def on_resize(self): pass + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 6fa2b5281a..c533b1fa9c 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -251,8 +251,8 @@ def scroll_to_row(self, row): self.table.scrollRowToVisible(row) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def _add_column(self, heading, accessor): column_identifier = at(accessor) diff --git a/cocoa/src/toga_cocoa/widgets/textinput.py b/cocoa/src/toga_cocoa/widgets/textinput.py index af3eb4ae41..a7f6de38ab 100644 --- a/cocoa/src/toga_cocoa/widgets/textinput.py +++ b/cocoa/src/toga_cocoa/widgets/textinput.py @@ -95,7 +95,7 @@ def rehint(self): # print("REHINT TextInput", self, # self._impl.intrinsicContentSize().width, self._impl.intrinsicContentSize().height # ) - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.intrinsicContentSize().height def set_on_change(self, handler): diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 4d49456179..a12c7c261d 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -319,5 +319,5 @@ def set_on_double_click(self, handler): pass def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/src/toga_cocoa/widgets/webview.py b/cocoa/src/toga_cocoa/widgets/webview.py index 4d54713dce..3a5284f025 100644 --- a/cocoa/src/toga_cocoa/widgets/webview.py +++ b/cocoa/src/toga_cocoa/widgets/webview.py @@ -117,5 +117,5 @@ def invoke_javascript(self, javascript): self.native.evaluateJavaScript(javascript, completionHandler=None) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index a60183c512..b2ebd72d3b 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -57,6 +57,11 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.superview is None + assert self.native.window is None + def assert_alignment(self, expected): assert self.alignment == expected @@ -95,6 +100,16 @@ def width(self): def height(self): return self.native.frame.size.height + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.superview is not None + assert self.native.window is not None + + # size and position is as expected. + assert (self.native.frame.size.width, self.native.frame.size.height) == size + assert (self.native.frame.origin.x, self.native.frame.origin.y) == position + def assert_width(self, min_width, max_width): assert ( min_width <= self.width <= max_width @@ -117,3 +132,11 @@ def background_color(self): async def press(self): self.native.performClick(None) + + @property + def is_hidden(self): + return self.native.isHidden() + + @property + def has_focus(self): + return self.native.window.firstResponder == self.native diff --git a/cocoa/tests_backend/widgets/textinput.py b/cocoa/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..5be2b0116a --- /dev/null +++ b/cocoa/tests_backend/widgets/textinput.py @@ -0,0 +1,15 @@ +from toga_cocoa.libs import NSTextField, NSTextView + +from .base import SimpleProbe + + +class TextInputProbe(SimpleProbe): + native_class = NSTextField + + @property + def has_focus(self): + # When the NSTextField gets focus, a field editor is created, and that editor + # has the original widget as the delegate. The first responder is the Field Editor. + return isinstance(self.native.window.firstResponder, NSTextView) and ( + self.native.window.firstResponder.delegate == self.native + ) diff --git a/core/src/toga/style/applicator.py b/core/src/toga/style/applicator.py index 543ecdbcd6..33e733c15b 100644 --- a/core/src/toga/style/applicator.py +++ b/core/src/toga/style/applicator.py @@ -17,14 +17,26 @@ def set_bounds(self): self.widget.layout.content_height, ) for child in self.widget.children: - if child.applicator: - child.applicator.set_bounds() + child.applicator.set_bounds() def set_text_alignment(self, alignment): self.widget._impl.set_alignment(alignment) def set_hidden(self, hidden): self.widget._impl.set_hidden(hidden) + for child in self.widget.children: + # If the parent is hidden, then so are all children. However, if the + # parent is visible, then the child's explicit visibility style is + # taken into account. This visibility cascades into any + # grandchildren. + # + # parent hidden child hidden style child final hidden state + # ============= ================== ======================== + # True True True + # True False True + # False True True + # False False False + child.applicator.set_hidden(hidden or child.style._hidden) def set_font(self, font): # Changing the font of a widget can make the widget change size, diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index 3462bb0ee8..e56f791f66 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -42,7 +42,7 @@ ###################################################################### DISPLAY_CHOICES = Choices(PACK, NONE) -VISIBILITY_CHOICES = Choices(VISIBLE, HIDDEN, NONE) +VISIBILITY_CHOICES = Choices(VISIBLE, HIDDEN) DIRECTION_CHOICES = Choices(ROW, COLUMN) ALIGNMENT_CHOICES = Choices(LEFT, RIGHT, TOP, BOTTOM, CENTER, default=True) @@ -81,9 +81,14 @@ class IntrinsicSize(BaseIntrinsicSize): _depth = -1 - def _debug(self, *args): + def _debug(self, *args): # pragma: no cover print(" " * self.__class__._depth, *args) + @property + def _hidden(self): + "Does this style declaration define a object that should be hidden" + return self.visibility == HIDDEN + def apply(self, prop, value): if self._applicator: if prop == "text_align": @@ -197,17 +202,25 @@ def _layout_node(self, node, alloc_width, alloc_height, scale, root=False): width, height = self._layout_row_children( node, available_width, available_height, scale ) - - if root: - # self._debug("ROOT NODE") - width = max(width, available_width) - height = max(height, available_height) - else: # self._debug("NO CHILDREN", available_width) width = available_width height = available_height + if root: + # A root node always expands to all available width and height, + # no matter how much space the child layout requires. + # self._debug("ROOT NODE") + width = max(width, available_width) + height = max(height, available_height) + else: + # If an explicit width/height was given, that specification + # overrides the width/height evaluated by the layout of children + if self.width: + width = scale(self.width) + if self.height: + height = scale(self.height) + # self._debug("FINAL SIZE", width, height) node.layout.content_width = int(width) node.layout.content_height = int(height) @@ -280,9 +293,10 @@ def _layout_row_children(self, node, available_width, available_height, scale): width += child_width available_width -= child_width - available_width = max(0, available_width) + available_width = max(0, available_width) + if full_flex: - # self._debug("q =",available_width, full_flex, available_width / full_flex) + # self._debug("q =", available_width, full_flex, available_width / full_flex) quantum = available_width / full_flex else: quantum = 0 diff --git a/core/src/toga/widgets/activityindicator.py b/core/src/toga/widgets/activityindicator.py index edf5feb174..19cb0eb220 100644 --- a/core/src/toga/widgets/activityindicator.py +++ b/core/src/toga/widgets/activityindicator.py @@ -34,6 +34,10 @@ def enabled(self): def enabled(self, value): pass + def focus(self): + "No-op; ActivityIndicator cannot accept input focus" + pass + @property def is_running(self): """Determine if the activity indicator is currently running. diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 68f8fd21e1..c2d29ab717 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -1,4 +1,3 @@ -import warnings from builtins import id as identifier from travertino.node import Node @@ -18,7 +17,7 @@ def __init__(self, *args, **kwargs): def __setitem__(self, key, value): # We do not want to allow setting items directly but to use the "add" # method instead. - raise RuntimeError("WidgetRegistry does not allow using item settings directly") + raise RuntimeError("Widgets cannot be directly added to a registry") def update(self, widgets): for widget in widgets: @@ -28,7 +27,7 @@ def add(self, widget): if widget.id in self: # Prevent from adding the same widget twice # or adding 2 widgets with the same id - raise KeyError(f'There is already a widget with "{widget.id}" id') + raise KeyError(f"There is already a widget with the id {widget.id!r}") super().__setitem__(widget.id, widget) def remove(self, id): @@ -39,41 +38,25 @@ def __iter__(self): class Widget(Node): - """This is the base widget implementation that all widgets in Toga derive - from. - - It defines the interface for core functionality for children, styling, - layout and ownership by specific App and Window. - - Apart from the above, this is an abstract implementation which must - be made concrete by some platform-specific code for the _apply_layout - method. - - Args: - id (str): An identifier for this widget. - enabled (bool): Whether or not interaction with the button is possible, defaults to `True`. - style: An optional style object. - If no style is provided then a new one will be created for the widget. - """ + _MIN_WIDTH = 100 + _MIN_HEIGHT = 100 def __init__( self, id=None, - enabled=True, style=None, - factory=None, # DEPRECATED ! ): - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### + """Create a base Toga widget. + + This is an abstract base class; it cannot be instantiated. + + :param id: The ID for the widget. + :param style: A style object. If no style is provided, a default style + will be applied to the widget. + """ super().__init__( - style=style if style else Pack(), applicator=TogaApplicator(self) + style=style if style else Pack(), + applicator=TogaApplicator(self), ) self._id = str(id) if id else str(identifier(self)) @@ -88,13 +71,18 @@ def __repr__(self): @property def id(self): - """The node identifier. This id can be used to target styling - directives.""" + """The unique identifier for the widget.""" return self._id @property def tab_index(self): - """The position of the widget in the focus chain for the window.""" + """The position of the widget in the focus chain for the window. + + .. note:: + + This is a beta feature. The ``tab_index`` API may change in + future. + """ return self._impl.get_tab_index() @tab_index.setter @@ -104,8 +92,9 @@ def tab_index(self, tab_index): def add(self, *children): """Add the provided widgets as children of this widget. - If a node already has a different parent, it will be moved over. This - does nothing if a node already is a child of this node. + If a child widget already has a parent, it will be re-parented as a + child of this widget. If the child widget is already a child of this + widget, there is no change. Raises ``ValueError`` if this widget cannot have children. @@ -124,8 +113,7 @@ def add(self, *children): child.app = self.app child.window = self.window - if self._impl: - self._impl.add_child(child._impl) + self._impl.add_child(child._impl) if self.window: self.window.content.refresh() @@ -133,8 +121,9 @@ def add(self, *children): def insert(self, index, child): """Insert a widget as a child of this widget. - If the node already has a parent, ownership of the widget will be - transferred. + If a child widget already has a parent, it will be re-parented as a + child of this widget. If the child widget is already a child of this + widget, there is no change. Raises ``ValueError`` if this node cannot have children. @@ -154,8 +143,7 @@ def insert(self, index, child): child.app = self.app child.window = self.window - if self._impl: - self._impl.insert_child(index, child._impl) + self._impl.insert_child(index, child._impl) if self.window: self.window.content.refresh() @@ -163,9 +151,10 @@ def insert(self, index, child): def remove(self, *children): """Remove the provided widgets as children of this node. - This does nothing if a given node is not a child of this node. + Any nominated child widget that is not a child of this widget will + not have any change in parentage. - Raises ``ValueError`` if this node is a leaf, and cannot have children. + Raises ``ValueError`` if this widget cannot have children. :param children: The child nodes to remove. """ @@ -176,8 +165,7 @@ def remove(self, *children): child.app = None child.window = None - if self._impl: - self._impl.remove_child(child._impl) + self._impl.remove_child(child._impl) if self.window: self.window.content.refresh() @@ -196,36 +184,24 @@ def app(self): @app.setter def app(self, app): - # If the widget is already assigned to an app, + # If the widget is already assigned to an app if self._app: - if app is None: - # Deregister the widget. - self._app.widgets.remove(self.id) - elif self._app != app: - # raise an error when we already have an app and attempt to override it - # with a different app - raise ValueError("Widget %s is already associated with an App" % self) - else: + if self._app == app: # If app is the same as the previous app, return return - if self._impl: - self._app = app - self._impl.set_app(app) - for child in self.children: - child.app = app + # Deregister the widget from the old app + self._app.widgets.remove(self.id) + + self._app = app + self._impl.set_app(app) + for child in self.children: + child.app = app if app is not None: # Add this widget to the application widget registry app.widgets.add(self) - # Provide an extension point for widgets with - # more complex widget hierarchies - self._set_app(app) - - def _set_app(self, app): - pass - @property def window(self): """The window to which this widget belongs. @@ -242,24 +218,15 @@ def window(self, window): self.window.widgets.remove(self.id) self._window = window - if self._impl: - self._impl.set_window(window) + self._impl.set_window(window) - if self._children is not None: - for child in self._children: - child.window = window + for child in self.children: + child.window = window if window is not None: # Add this widget to the window's widget registry window.widgets.add(self) - # Provide an extension point for widgets with - # more complex widget hierarchies - self._set_window(window) - - def _set_window(self, window): - pass - @property def enabled(self): """Is the widget currently enabled? i.e., can the user interact with the @@ -275,6 +242,8 @@ def refresh(self): # Refresh the layout if self._root: + # We're not the root of the node heirarchy; + # defer the refresh call to the root node. self._root.refresh() else: self.refresh_sublayouts() @@ -287,6 +256,12 @@ def refresh_sublayouts(self): child.refresh_sublayouts() def focus(self): - """Set this widget to have the current input focus.""" - if self._impl is not None: - self._impl.focus() + """Give this widget the input focus. + + This method is a no-op if the widget can't accept focus. The ability of + a widget to accept focus is platform-dependent. In general, on desktop + platforms you can focus any widget that can accept user input, while + on mobile platforms focus is limited to widgets that accept text input + (i.e., widgets that cause the virtual keyboard to appear). + """ + self._impl.focus() diff --git a/core/src/toga/widgets/box.py b/core/src/toga/widgets/box.py index 6ed77cde5b..1c67e84d58 100644 --- a/core/src/toga/widgets/box.py +++ b/core/src/toga/widgets/box.py @@ -19,13 +19,14 @@ def __init__( """ super().__init__(id=id, style=style) + # Create a platform specific implementation of a Box + self._impl = self.factory.Box(interface=self) + + # Children need to be added *after* the impl has been created. self._children = [] if children: self.add(*children) - # Create a platform specific implementation of a Box - self._impl = self.factory.Box(interface=self) - @property def enabled(self): """Is the widget currently enabled? i.e., can the user interact with the @@ -38,3 +39,7 @@ def enabled(self): @enabled.setter def enabled(self, value): pass + + def focus(self): + """No-op; Box cannot accept input focus""" + pass diff --git a/core/src/toga/widgets/button.py b/core/src/toga/widgets/button.py index 5e59b1dcde..e2b9d7c393 100644 --- a/core/src/toga/widgets/button.py +++ b/core/src/toga/widgets/button.py @@ -25,7 +25,7 @@ def __init__( :param enabled: Is the button enabled (i.e., can it be pressed?). Optional; by default, buttons are created in an enabled state. """ - super().__init__(id=id, style=style, enabled=enabled) + super().__init__(id=id, style=style) # Create a platform specific implementation of a Button self._impl = self.factory.Button(interface=self) diff --git a/core/src/toga/widgets/datepicker.py b/core/src/toga/widgets/datepicker.py index 5d78c9ea25..1c03f9f79f 100644 --- a/core/src/toga/widgets/datepicker.py +++ b/core/src/toga/widgets/datepicker.py @@ -15,7 +15,7 @@ class DatePicker(Widget): a new one will be created for the widget. """ - MIN_WIDTH = 200 + _MIN_WIDTH = 200 def __init__( self, diff --git a/core/src/toga/widgets/divider.py b/core/src/toga/widgets/divider.py index 71f4df61f9..7c1c03131c 100644 --- a/core/src/toga/widgets/divider.py +++ b/core/src/toga/widgets/divider.py @@ -40,6 +40,10 @@ def enabled(self): def enabled(self, value): pass + def focus(self): + "No-op; Divider cannot accept input focus" + pass + @property def direction(self): """The direction in which the visual separator will be drawn. diff --git a/core/src/toga/widgets/label.py b/core/src/toga/widgets/label.py index 6540f95b5e..2ecedbb7ae 100644 --- a/core/src/toga/widgets/label.py +++ b/core/src/toga/widgets/label.py @@ -24,6 +24,10 @@ def __init__( self.text = text + def focus(self): + "No-op; Label cannot accept input focus" + pass + @property def text(self): """The text displayed by the label. diff --git a/core/src/toga/widgets/numberinput.py b/core/src/toga/widgets/numberinput.py index 18bf8bc661..fb0de569be 100644 --- a/core/src/toga/widgets/numberinput.py +++ b/core/src/toga/widgets/numberinput.py @@ -27,8 +27,6 @@ class NumberInput(Widget): **ex: """ - MIN_WIDTH = 100 - def __init__( self, id=None, diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index 5dfc1cb1ae..84093e7e23 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -346,12 +346,20 @@ def current_tab(self, current_tab): current_tab = current_tab.index self._impl.set_current_tab_index(current_tab) - def _set_app(self, app): + @Widget.app.setter + def app(self, app): + # Invoke the superclass property setter + Widget.app.fset(self, app) + # Also assign the app to the content in the container for item in self._content: item._content.app = app - def _set_window(self, window): + @Widget.window.setter + def window(self, window): + # Invoke the superclass property setter + Widget.window.fset(self, window) + # Also assign the window to the content in the container for item in self._content: item._content.window = window diff --git a/core/src/toga/widgets/scrollcontainer.py b/core/src/toga/widgets/scrollcontainer.py index b04e9efca4..d4dc1f5336 100644 --- a/core/src/toga/widgets/scrollcontainer.py +++ b/core/src/toga/widgets/scrollcontainer.py @@ -53,12 +53,20 @@ def __init__( self.content = content self.on_scroll = on_scroll - def _set_app(self, app): + @Widget.app.setter + def app(self, app): + # Invoke the superclass property setter + Widget.app.fset(self, app) + # Also assign the app to the content in the container if self.content: self.content.app = app - def _set_window(self, window): + @Widget.window.setter + def window(self, window): + # Invoke the superclass property setter + Widget.window.fset(self, window) + # Also assign the window to the content in the container if self._content: self._content.window = window diff --git a/core/src/toga/widgets/selection.py b/core/src/toga/widgets/selection.py index 8596c26436..31c3bb6a9c 100644 --- a/core/src/toga/widgets/selection.py +++ b/core/src/toga/widgets/selection.py @@ -15,8 +15,6 @@ class Selection(Widget): items (``list`` of ``str``): The items for the selection. """ - MIN_WIDTH = 100 - def __init__( self, id=None, diff --git a/core/src/toga/widgets/splitcontainer.py b/core/src/toga/widgets/splitcontainer.py index b0300df273..9493c35fd8 100644 --- a/core/src/toga/widgets/splitcontainer.py +++ b/core/src/toga/widgets/splitcontainer.py @@ -102,13 +102,21 @@ def content(self, content): self._impl.add_content(position, widget._impl, flex) widget.refresh() - def _set_app(self, app): + @Widget.app.setter + def app(self, app): + # Invoke the superclass property setter + Widget.app.fset(self, app) + # Also assign the app to the content in the container if self.content: for content in self.content: content.app = app - def _set_window(self, window): + @Widget.window.setter + def window(self, window): + # Invoke the superclass property setter + Widget.window.fset(self, window) + # Also assign the window to the content in the container if self._content: for content in self._content: diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 18102e1edd..5d6bdaa04c 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -47,9 +47,6 @@ class Table(Widget): >>> data = ['item 1', 'item 2', 'item 3'] """ - MIN_WIDTH = 100 - MIN_HEIGHT = 100 - def __init__( self, headings, diff --git a/core/src/toga/widgets/webview.py b/core/src/toga/widgets/webview.py index 604946fea0..517996a67e 100644 --- a/core/src/toga/widgets/webview.py +++ b/core/src/toga/widgets/webview.py @@ -22,9 +22,6 @@ class WebView(Widget): :type on_webview_load: ``callable`` """ - MIN_WIDTH = 100 - MIN_HEIGHT = 100 - def __init__( self, id=None, diff --git a/core/tests/style/test_applicator.py b/core/tests/style/test_applicator.py new file mode 100644 index 0000000000..c68385b75c --- /dev/null +++ b/core/tests/style/test_applicator.py @@ -0,0 +1,156 @@ +import pytest + +import toga +from toga.colors import REBECCAPURPLE +from toga.fonts import FANTASY +from toga.style.pack import HIDDEN, RIGHT, VISIBLE +from toga_dummy.utils import assert_action_performed_with + + +# Create the simplest possible widget with a concrete implementation that will +# allow children +class TestWidget(toga.Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._impl = self.factory.Widget(self) + self._children = [] + + +# Create the simplest possible widget with a concrete implementation that cannot +# have children. +class TestLeafWidget(toga.Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._impl = self.factory.Widget(self) + + +@pytest.fixture +def grandchild(): + return TestLeafWidget(id="grandchild_id") + + +@pytest.fixture +def child(grandchild): + child = TestWidget(id="child_id") + child.add(grandchild) + + return child + + +@pytest.fixture +def widget(child): + widget = TestWidget(id="widget_id") + widget.add(child) + + return widget + + +def test_refresh(widget): + "Refresh requests are passed to the widget" + widget.applicator.refresh() + + assert_action_performed_with(widget, "refresh") + + +def test_set_bounds(widget, child, grandchild): + "Bounds changes are passed to all widgets in the tree" + # Manually set location of the parent + widget.layout._origin_left = 100 + widget.layout._origin_top = 200 + widget.layout.content_width = 300 + widget.layout.content_height = 400 + + # Manually set location of the child + child.layout._origin_left = 10 + child.layout._origin_top = 20 + child.layout.content_width = 30 + child.layout.content_height = 40 + + # Manually set location of the grandchild + grandchild.layout._origin_left = 1 + grandchild.layout._origin_top = 2 + grandchild.layout.content_width = 3 + grandchild.layout.content_height = 4 + + # Propegate the boundsq + widget.applicator.set_bounds() + + assert_action_performed_with( + widget, "set bounds", x=100, y=200, width=300, height=400 + ) + assert_action_performed_with(child, "set bounds", x=10, y=20, width=30, height=40) + assert_action_performed_with(grandchild, "set bounds", x=1, y=2, width=3, height=4) + + +def test_text_alignment(widget): + "Text alignment can be set on a widget" + widget.applicator.set_text_alignment(RIGHT) + + assert_action_performed_with(widget, "set alignment", alignment=RIGHT) + + +@pytest.mark.parametrize( + "child_visibility, grandchild_visibility, value, " + "widget_hidden, child_hidden, grandchild_hidden", + [ + # Set widget hidden. All widgets are always hidden, + # no matter the actual style setting. + (VISIBLE, VISIBLE, True, True, True, True), + (VISIBLE, HIDDEN, True, True, True, True), + (HIDDEN, VISIBLE, True, True, True, True), + (HIDDEN, HIDDEN, True, True, True, True), + # Set widget visible. Visibility only cascades + # as far as the first HIDDEN widget + (VISIBLE, VISIBLE, False, False, False, False), + (VISIBLE, HIDDEN, False, False, False, True), + (HIDDEN, VISIBLE, False, False, True, True), + (HIDDEN, HIDDEN, False, False, True, True), + ], +) +def test_set_hidden( + widget, + child, + grandchild, + child_visibility, + grandchild_visibility, + value, + widget_hidden, + child_hidden, + grandchild_hidden, +): + """Widget visibility can be controlled, and is transitive into children""" + # Set the explicit visibility of the child and grandchild + child.style.visibility = child_visibility + grandchild.style.visibility = grandchild_visibility + + # Set widget visibility + widget.applicator.set_hidden(value) + + assert_action_performed_with(widget, "set hidden", hidden=widget_hidden) + assert_action_performed_with(child, "set hidden", hidden=child_hidden) + assert_action_performed_with(grandchild, "set hidden", hidden=grandchild_hidden) + + # The style property of the child and grandchild hasn't changed. + assert child.style.visibility == child_visibility + assert grandchild.style.visibility == grandchild_visibility + + +def test_set_font(widget): + "A font change can be applied to a widget" + widget.applicator.set_font(FANTASY) + + assert_action_performed_with(widget, "set font", font=FANTASY) + + +def test_set_color(widget): + "A color change can be applied to a widget" + widget.applicator.set_color(REBECCAPURPLE) + + assert_action_performed_with(widget, "set color", color=REBECCAPURPLE) + + +def test_set_background_color(child, widget): + "A background color change can be applied to a widget" + widget.applicator.set_background_color(REBECCAPURPLE) + + assert_action_performed_with(widget, "set background color", color=REBECCAPURPLE) diff --git a/core/tests/style/test_pack.py b/core/tests/style/test_pack.py index c2db14d701..b8d1167f50 100644 --- a/core/tests/style/test_pack.py +++ b/core/tests/style/test_pack.py @@ -1777,3 +1777,91 @@ def test_rtl_alignment_right(self): ], }, ) + + def test_fixed_size(self): + root = TestNode( + "app", + style=Pack(), + children=[ + TestNode( + "first", + style=Pack(width=100, height=100), + size=(at_least(0), at_least(0)), + ), + TestNode( + "second", + style=Pack(width=100, height=100), + size=(at_least(50), at_least(50)), + children=[ + TestNode( + "child", + style=Pack(width=50, height=50), + size=(at_least(0), at_least(0)), + ), + ], + ), + ], + ) + + # Minimum size + root.style.layout(root, TestViewport(0, 0, dpi=96)) + self.assertLayout( + root, + (200, 100), + { + "origin": (0, 0), + "content": (200, 100), + "children": [ + {"origin": (0, 0), "content": (100, 100)}, + { + "origin": (100, 0), + "content": (100, 100), + "children": [ + {"origin": (100, 0), "content": (50, 50)}, + ], + }, + ], + }, + ) + + # Normal size + root.style.layout(root, TestViewport(640, 480, dpi=96)) + self.assertLayout( + root, + (640, 480), + { + "origin": (0, 0), + "content": (640, 480), + "children": [ + {"origin": (0, 0), "content": (100, 100)}, + { + "origin": (100, 0), + "content": (100, 100), + "children": [ + {"origin": (100, 0), "content": (50, 50)}, + ], + }, + ], + }, + ) + + # HiDPI Normal size + root.style.layout(root, TestViewport(640, 480, dpi=144)) + self.assertLayout( + root, + (640, 480), + { + "origin": (0, 0), + "content": (640, 480), + "children": [ + {"origin": (0, 0), "content": (150, 150)}, + { + "origin": (150, 0), + "content": (150, 150), + "children": [ + {"origin": (150, 0), "content": (75, 75)}, + ], + }, + ], + }, + ) diff --git a/core/tests/test_app.py b/core/tests/test_app.py index b396f48933..990d058d3b 100644 --- a/core/tests/test_app.py +++ b/core/tests/test_app.py @@ -165,7 +165,7 @@ async def test_handler(sender): self.app, "loop:call_soon_threadsafe", handler=test_handler, - args=(self.app,), + args=(None,), ) diff --git a/core/tests/test_widget_registry.py b/core/tests/test_widget_registry.py index db79a14760..27def4e162 100644 --- a/core/tests/test_widget_registry.py +++ b/core/tests/test_widget_registry.py @@ -1,92 +1,132 @@ -from unittest.mock import Mock +import pytest +import toga from toga.widgets.base import WidgetRegistry -from toga_dummy.utils import TestCase - - -def widget_mock(id): - widget = Mock() - widget.id = id - widget.__repr__ = Mock(return_value=f"Widget(id={id})") - return widget - - -class TestWidgetsRegistry(TestCase): - def setUp(self): - super().setUp() - self.widget_registry = WidgetRegistry() - - def test_empty_registry(self): - self.assertEqual(len(self.widget_registry), 0) - self.assertEqual(list(self.widget_registry), []) - self.assertEqual(str(self.widget_registry), "{}") - - def test_add_widget(self): - id1 = 1234 - widget = widget_mock(id1) - self.widget_registry.add(widget) - - self.assertEqual(len(self.widget_registry), 1) - self.assertEqual(list(self.widget_registry), [widget]) - self.assertEqual(str(self.widget_registry), "{1234: Widget(id=1234)}") - self.assertEqual(self.widget_registry[id1], widget) - - def test_add_two_widgets(self): - id1, id2 = 1234, 6789 - widget1, widget2 = widget_mock(id1), widget_mock(id2) - self.widget_registry.add(widget1) - self.widget_registry.add(widget2) - - self.assertEqual(len(self.widget_registry), 2) - self.assertEqual(set(self.widget_registry), {widget1, widget2}) - self.assertEqual(self.widget_registry[id1], widget1) - self.assertEqual(self.widget_registry[id2], widget2) - - def test_update_widgets(self): - id1, id2, id3 = 1234, 6789, 9821 - widget1, widget2, widget3 = widget_mock(id1), widget_mock(id2), widget_mock(id3) - self.widget_registry.update({widget1, widget2, widget3}) - - self.assertEqual(len(self.widget_registry), 3) - self.assertEqual(set(self.widget_registry), {widget1, widget2, widget3}) - self.assertEqual(self.widget_registry[id1], widget1) - self.assertEqual(self.widget_registry[id2], widget2) - self.assertEqual(self.widget_registry[id3], widget3) - - def test_remove_widget(self): - id1, id2, id3 = 1234, 6789, 9821 - widget1, widget2, widget3 = widget_mock(id1), widget_mock(id2), widget_mock(id3) - self.widget_registry.update({widget1, widget2, widget3}) - self.widget_registry.remove(id2) - - self.assertEqual(len(self.widget_registry), 2) - self.assertEqual(set(self.widget_registry), {widget1, widget3}) - self.assertEqual(self.widget_registry[id1], widget1) - self.assertEqual(self.widget_registry[id3], widget3) - - def test_add_same_widget_twice(self): - id1 = 1234 - widget = widget_mock(id1) - self.widget_registry.add(widget) - self.assertRaises(KeyError, self.widget_registry.add, widget) - - self.assertEqual(len(self.widget_registry), 1) - self.assertEqual(list(self.widget_registry), [widget]) - self.assertEqual(str(self.widget_registry), "{1234: Widget(id=1234)}") - self.assertEqual(self.widget_registry[id1], widget) - - def test_two_widgets_with_same_name(self): - id1 = 1234 - widget1, widget2 = widget_mock(id1), widget_mock(id1) - self.widget_registry.add(widget1) - self.assertRaises(KeyError, self.widget_registry.add, widget2) - - self.assertEqual(len(self.widget_registry), 1) - self.assertEqual(list(self.widget_registry), [widget1]) - self.assertEqual(str(self.widget_registry), "{1234: Widget(id=1234)}") - self.assertEqual(self.widget_registry[id1], widget1) - - def test_using_setitem_directly(self): - id1 = 1234 - widget = widget_mock(id1) - self.assertRaises(RuntimeError, self.widget_registry.__setitem__, id1, widget) + + +@pytest.fixture +def widget_registry(): + return WidgetRegistry() + + +# Create the simplest possible widget with a concrete implementation +class TestWidget(toga.Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._impl = self.factory.Widget(self) + + def __repr__(self): + return f"Widget(id={self.id!r})" + + +def test_empty_registry(widget_registry): + assert len(widget_registry) == 0 + assert list(widget_registry) == [] + assert str(widget_registry) == "{}" + + +def test_add_widget(widget_registry): + "Widgets can be added to the registry" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget_registry.add(widget1) + + assert len(widget_registry) == 1 + assert list(widget_registry) == [widget1] + assert str(widget_registry) == "{'widget-1': Widget(id='widget-1')}" + assert widget_registry["widget-1"] == widget1 + + # Add a second widget + widget2 = TestWidget(id="widget-2") + widget_registry.add(widget2) + + assert len(widget_registry) == 2 + assert widget_registry["widget-1"] == widget1 + assert widget_registry["widget-2"] == widget2 + + +def test_update_widgets(widget_registry): + "The registry can be bulk updated" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget_registry.add(widget1) + + widget2 = TestWidget(id="widget-2") + widget3 = TestWidget(id="widget-3") + widget4 = TestWidget(id="widget-4") + widget_registry.update({widget2, widget3, widget4}) + + assert len(widget_registry) == 4 + assert widget_registry["widget-1"] == widget1 + assert widget_registry["widget-2"] == widget2 + assert widget_registry["widget-3"] == widget3 + assert widget_registry["widget-4"] == widget4 + + +def test_remove_widget(widget_registry): + "A widget can be removed from the repository" + "Widgets can be added to the registry" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget2 = TestWidget(id="widget-2") + widget_registry.update({widget1, widget2}) + + assert len(widget_registry) == 2 + + widget_registry.remove("widget-2") + + assert widget_registry["widget-1"] == widget1 + assert "widget-2" not in widget_registry + + +def test_add_same_widget_twice(widget_registry): + "A widget cannot be added to the same registry twice" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget_registry.add(widget1) + + assert len(widget_registry) == 1 + + # Add the widget again; this raises an error + with pytest.raises( + KeyError, + match=r"There is already a widget with the id 'widget-1'", + ): + widget_registry.add(widget1) + + # Widget is still there + assert len(widget_registry) == 1 + assert widget_registry["widget-1"] == widget1 + + +def test_add_duplicate_id(widget_registry): + "A widget cannot be added to the same registry twice" + # Add a widget to the registry + widget1 = TestWidget(id="widget-1") + widget_registry.add(widget1) + + assert len(widget_registry) == 1 + + new_widget = TestWidget(id="widget-1") + + # Add the widget again; this raises an error + with pytest.raises( + KeyError, + match=r"There is already a widget with the id 'widget-1'", + ): + widget_registry.add(new_widget) + + # Widget is still there + assert len(widget_registry) == 1 + assert widget_registry["widget-1"] == widget1 + + +def test_setitem(widget_registry): + "Widgets cannot be directly assigned to the registry" + widget1 = TestWidget(id="widget-1") + + with pytest.raises( + RuntimeError, + match=r"Widgets cannot be directly added to a registry", + ): + widget_registry["new is"] = widget1 diff --git a/core/tests/widgets/test_activityindicator.py b/core/tests/widgets/test_activityindicator.py index 69ccd12036..b304f04c47 100644 --- a/core/tests/widgets/test_activityindicator.py +++ b/core/tests/widgets/test_activityindicator.py @@ -111,3 +111,10 @@ def test_initially_running(): # Assert that start was invoked on the impl as part of creation. assert_action_performed(activity_indicator, "start ActivityIndicator") + + +def test_focus_noop(activity_indicator): + "Focus is a no-op." + + activity_indicator.focus() + assert_action_not_performed(activity_indicator, "focus") diff --git a/core/tests/widgets/test_base.py b/core/tests/widgets/test_base.py index 5828d874bb..9dae7f8333 100644 --- a/core/tests/widgets/test_base.py +++ b/core/tests/widgets/test_base.py @@ -1,413 +1,942 @@ -from unittest.mock import Mock +from unittest.mock import Mock, call + +import pytest import toga from toga.style import Pack -from toga_dummy.utils import TestCase +from toga_dummy.utils import ( + EventLog, + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, + assert_attribute_not_set, + attribute_value, +) + + +# Create the simplest possible widget with a concrete implementation that will +# allow children +class TestWidget(toga.Widget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._impl = self.factory.Widget(self) + self._children = [] -# Create the simplest possible widget with a concrete implementation -class Widget(toga.Widget): +# Create the simplest possible widget with a concrete implementation that cannot +# have children. +class TestLeafWidget(toga.Widget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._impl = self.factory.Widget(self) -class WidgetTests(TestCase): - def setUp(self): - super().setUp() - - self.id = "widget_id" - self.style = Pack(padding=666) - - self.widget = Widget( - id=self.id, - style=self.style, - ) - - def test_arguments_were_set_correctly(self): - self.assertEqual(self.widget.id, self.id) - self.assertEqual(self.widget.style.padding, self.style.padding) - - def test_create_widget_with_no_style(self): - widget = toga.Widget() - self.assertTrue(isinstance(widget.style, Pack)) - - def test_enabled_with_None(self): - # Using a Button for test because we need a concrete implementation to use this property. - widget = toga.Button("Hello") - widget.enabled = None - self.assertFalse(widget.enabled) - self.assertValueSet(widget, "enabled", False) - - def test_adding_child(self): - self.assertIsNone(self.widget.app) - self.assertIsNone(self.widget.window) - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - # Create a child widget to add to widget. - child = toga.Widget() - - with self.assertRaises(ValueError, msg="Widget cannot have children."): - self.widget.add(child) - - # Deliberately set widget._children = [] to allow it to have children. - # Only for test purposes! - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - - def test_adding_child_with_app(self): - app = toga.App("Test App", "org.beeware.test") - self.widget.app = app - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - - # Create a child widget to add to widget. - child_id = "child-id" - child = Widget(id=child_id) - - # Deliberately set widget._children = [] to allow it to have children. - # Only for test purposes! - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(self.widget.app, app) - self.assertEqual(child.app, app) - self.assertEqual(len(app.widgets), 2) - self.assertEqual(app.widgets[self.id], self.widget) - self.assertEqual(app.widgets[child_id], child) - - def test_adding_child_with_window(self): - window = toga.Window() - window.content = Mock() - self.widget.window = window - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - - # Create a child widget to add to widget. - child_id = "child-id" - child = Widget(id=child_id) - - # Deliberately set widget._children = [] to allow it to have children. - # Only for test purposes! - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(self.widget.window, window) - self.assertEqual(child.window, window) - self.assertEqual(len(window.widgets), 2) - self.assertEqual(window.widgets[self.id], self.widget) - self.assertEqual(window.widgets[child_id], child) - - def test_adding_children(self): - self.assertEqual( - self.widget.children, [], "No children added, should return an empty list." - ) - # Create 2 children to add to widget. - child1 = toga.Widget() - child2 = toga.Widget() - - self.widget._children = [] - self.widget.add(child1, child2) - self.assertEqual(self.widget.children, [child1, child2]) - - def test_adding_child_with_existing_parent(self): - # Create a second parent widget. - widget2 = toga.Widget() - # Create a child widget to add to widget2 before adding to widget. - child = toga.Widget() - - widget2._children = [] - widget2.add(child) - self.assertEqual(widget2.children, [child]) - - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(widget2.children, []) - - def test_inserting_child_into_empty_list(self): - self.assertEqual( - self.widget.children, - [], - "No child was inserted, should return an empty list.", - ) - # Create a child widget to insert into widget. - child = toga.Widget() - - with self.assertRaises(ValueError, msg="Widget cannot have children."): - self.widget.insert(0, child) - - self.widget._children = [] - self.widget.insert(0, child) - self.assertEqual(self.widget.children, [child]) - - def test_inserting_child_into_list_containing_one_child(self): - self.assertEqual( - self.widget.children, - [], - "No child was inserted, should return an empty list.", - ) - # Create 2 children to insert into widget. - child1 = toga.Widget() - child2 = toga.Widget() - - self.widget._children = [] - self.widget.insert(0, child1) - self.widget.insert(1, child2) - self.assertEqual(self.widget.children, [child1, child2]) - - def test_inserting_child_into_list_containing_three_children(self): - self.assertEqual( - self.widget.children, - [], - "No child was inserted, should return an empty list.", - ) - # Create 3 children to add to widget. - child1 = toga.Widget() - child2 = toga.Widget() - child3 = toga.Widget() - # Create a child to insert into widget. - child4 = toga.Widget() - - self.widget._children = [] - self.widget.add(child1, child2, child3) - self.widget.insert(2, child4) - - self.assertEqual(self.widget.children, [child1, child2, child4, child3]) - - def test_inserting_child_with_existing_parent(self): - # Create a second parent widget. - widget2 = toga.Widget() - # Create a child widget to insert into widget2 before inserting into widget. - child = toga.Widget() - - widget2._children = [] - widget2.insert(0, child) - self.assertEqual(widget2.children, [child]) - - self.widget._children = [] - self.widget.insert(0, child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(widget2.children, []) - - def test_removing_child(self): - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - # Create a child widget to add then remove from widget. - child = toga.Widget() - - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - - self.widget.remove(child) - self.assertEqual(self.widget.children, []) - - def test_removing_child_with_app(self): - app = toga.App("Test App", "org.beeware.test") - self.widget.app = app - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - # Create a child widget to add then remove from widget. - child = Widget() - - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(len(app.widgets), 2) - - self.widget.remove(child) - self.assertEqual(self.widget.children, []) - self.assertEqual(len(app.widgets), 1) - self.assertEqual(app.widgets[self.id], self.widget) - - def test_removing_child_with_window(self): - window = toga.Window() - window.content = Mock() - self.widget.window = window - self.assertEqual( - self.widget.children, [], "No child was added, should return an empty list." - ) - # Create a child widget to add then remove from widget. - child = Widget() - - self.widget._children = [] - self.widget.add(child) - self.assertEqual(self.widget.children, [child]) - self.assertEqual(len(window.widgets), 2) - - self.widget.remove(child) - self.assertEqual(self.widget.children, []) - self.assertEqual(len(window.widgets), 1) - self.assertEqual(window.widgets[self.id], self.widget) - - def test_removing_children(self): - self.assertEqual( - self.widget.children, [], "No children added, should return an empty list." - ) - # Create 2 children to add then remove from widget. - child1 = toga.Widget() - child2 = toga.Widget() - - self.widget._children = [] - self.widget.add(child1, child2) - self.assertEqual(self.widget.children, [child1, child2]) - - self.widget.remove(child1, child2) - self.assertEqual(self.widget.children, []) - - def test_removing_two_out_of_three_children(self): - self.assertEqual( - self.widget.children, [], "No children added, should return am empty list." - ) - # Create 3 children to add to widget, 2 of which will be removed. - child1 = toga.Widget() - child2 = toga.Widget() - child3 = toga.Widget() - - self.widget._children = [] - self.widget.add(child1, child2, child3) - self.assertEqual(self.widget.children, [child1, child2, child3]) - - self.widget.remove(child1, child3) - self.assertEqual(self.widget.children, [child2]) - - def test_set_app(self): - "A widget can be assigned to an app" - app = toga.App("Test App", "org.beeware.test") - self.assertEqual(len(app.widgets), 0) - self.widget.app = app - - # The app has been assigned - self.assertEqual(self.widget.app, app) - self.assertEqual(len(app.widgets), 1) - self.assertEqual(app.widgets[self.id], self.widget) - - # The app has been assigned to the underlying impl - self.assertValueSet(self.widget, "app", app) - - def test_set_app_with_children(self): - "A widget with children can be assigned to an app" - # Create 2 children to add to widget, 2 of which will be removed. - child_id1, child_id2 = "child-id1", "child-id2" - child1 = Widget(id=child_id1) - child2 = Widget(id=child_id2) - - self.widget._children = [] - self.widget.add(child1, child2) - - app = toga.App("Test App", "org.beeware.test") - self.assertEqual(len(app.widgets), 0) - - self.widget.app = app - - # The app has been assigned to all children - self.assertEqual(self.widget.app, app) - self.assertEqual(child1.app, app) - self.assertEqual(child2.app, app) - self.assertEqual(len(app.widgets), 3) - self.assertEqual(app.widgets[self.id], self.widget) - self.assertEqual(app.widgets[child_id1], child1) - self.assertEqual(app.widgets[child_id2], child2) - - # The app has been assigned to the underlying impls - self.assertValueSet(self.widget, "app", app) - self.assertValueSet(child1, "app", app) - self.assertValueSet(child2, "app", app) - - def test_repeat_set_app(self): - "If a widget is already assigned to an app, doing so again raises an error" - app = toga.App("Test App", "org.beeware.test") - self.widget.app = app - - # The app has been assigned - self.assertEqual(self.widget.app, app) - - # Assign the widget to the same app - app2 = toga.App("Test App", "org.beeware.test") - - with self.assertRaises(ValueError, msg="is already associated with an App"): - self.widget.app = app2 - - # The app is still assigned to the original app - self.assertEqual(self.widget.app, app) - self.assertEqual(list(app.widgets), [self.widget]) - - def test_repeat_same_set_app(self): - "If a widget is already assigned to an app, re-assigning to the same app is OK" - app = toga.App("Test App", "org.beeware.test") - self.widget.app = app - - # The app has been assigned - self.assertEqual(self.widget.app, app) - - # Assign the widget to the same app - self.widget.app = app - - # The app is still assigned - self.assertEqual(self.widget.app, app) - self.assertEqual(list(app.widgets), [self.widget]) - - def test_remove_app(self): - "A widget can be assigned to an app" - app = toga.App("Test App", "org.beeware.test") - self.assertEqual(len(app.widgets), 0) - - self.widget.app = app - self.widget.app = None - - # The app has been unassigned - self.assertIsNone(self.widget.app) - self.assertEqual(len(app.widgets), 0) - - # The app has been assigned to the underlying impl - self.assertValueSet(self.widget, "app", None) - - def test_set_window(self): - window = toga.Window() - self.assertEqual(len(window.widgets), 0) - self.widget.window = window - - self.assertEqual(len(window.widgets), 1) - self.assertEqual(window.widgets[self.id], self.widget) - - def test_replace_window(self): - window1, window2 = (toga.Window(), toga.Window()) - self.widget.window = window1 - self.assertEqual(len(window1.widgets), 1) - self.assertEqual(len(window2.widgets), 0) - - self.widget.window = window2 - self.assertEqual(len(window1.widgets), 0) - self.assertEqual(len(window2.widgets), 1) - self.assertEqual(window2.widgets[self.id], self.widget) - - def test_remove_window(self): - window = toga.Window() - self.assertEqual(len(window.widgets), 0) - - self.widget.window = window - self.widget.window = None - - self.assertEqual(len(window.widgets), 0) +@pytest.fixture +def widget(): + return TestWidget(id="widget_id", style=Pack(padding=666)) + + +def test_simple_widget(): + """A simple widget can be created""" + widget = TestWidget() + + # Round trip the impl/interface + assert widget._impl.interface == widget + assert_action_performed(widget, "create Widget") + + # Base properties of the widget have been set + assert widget.id == str(id(widget)) + assert isinstance(widget.style, Pack) + assert widget.style.padding == (0, 0, 0, 0) + + +def test_widget_created(widget): + """A widget can be created with arguments.""" + # Round trip the impl/interface + assert widget._impl.interface == widget + assert_action_performed(widget, "create Widget") + + # Base properties of the widget have been set + assert widget.enabled + assert widget.id == "widget_id" + assert isinstance(widget.style, Pack) + assert widget.style.padding == (666, 666, 666, 666) + + +def test_add_child_to_leaf(): + "A child cannot be added to a leaf node" + leaf = TestLeafWidget() + + # Widget doesn't have an app or window + assert leaf.app is None + assert leaf.window is None + + # Leaf nodes report an empty child list + assert leaf.children == [] + + # Create a child widget + child = TestLeafWidget() + + # Add the child. + with pytest.raises(ValueError, match=r"Cannot add children"): + leaf.add(child) + + +def test_add_child_without_app(widget): + "A child can be added to a node when there's no underlying app" + # Widget doesn't have an app or window + assert widget.app is None + assert widget.window is None + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget() + + # Add the child. + widget.add(child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app is None + assert child.window is None + + # The impl's add_child has been invoked + assert_action_performed_with(widget, "add child", child=child._impl) + + +def test_add_child(widget): + "A child can be added to a node when there's an app & window" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget(id="child_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child_id" not in app.widgets + + # Add the child. + widget.add(child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app == app + assert child.window == window + + # The impl's add_child has been invoked + assert_action_performed_with(widget, "add child", child=child._impl) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + # App's widget index has been updated + assert len(app.widgets) == 2 + assert app.widgets["widget_id"] == widget + assert app.widgets["child_id"] == child + + +def test_add_multiple_children(widget): + "Multiple children can be added in one call" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create 3 child widgets + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child1_id" not in app.widgets + assert "child2_id" not in app.widgets + assert "child3_id" not in app.widgets + + # Add the children. + widget.add(child1, child2, child3) + + # Widget knows about the child and vice versa + assert widget.children == [child1, child2, child3] + assert child1.parent == widget + assert child2.parent == widget + assert child3.parent == widget + + # Children has inherited parent's app/window details + assert child1.app == app + assert child1.window == window + + assert child2.app == app + assert child2.window == window + + assert child3.app == app + assert child3.window == window + + # The impl's add_child has been invoked 3 time + assert_action_performed_with(widget, "add child", child=child1._impl) + assert_action_performed_with(widget, "add child", child=child2._impl) + assert_action_performed_with(widget, "add child", child=child3._impl) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + # App's widget index has been updated + assert len(app.widgets) == 4 + assert app.widgets["widget_id"] == widget + assert app.widgets["child1_id"] == child1 + assert app.widgets["child2_id"] == child2 + assert app.widgets["child3_id"] == child3 + + +def test_reparent_child(widget): + "A widget can be reparented" + # Create a second parent widget, and add a child to it + other = TestWidget(id="other") + child = TestLeafWidget(id="child_id") + other.add(child) + + assert other.children == [child] + assert child.parent == other + + # Add the child to the widget + widget.add(child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Original parent as lost the child + assert other.children == [] + + # The impl's add_child has been invoked + assert_action_performed_with(widget, "add child", child=child._impl) + + +def test_reparent_child_to_self(widget): + "Reparenting a widget to the same parent is a no-op" + # Add a child to the widget + child = TestLeafWidget(id="child_id") + widget.add(child) + + assert widget.children == [child] + assert child.parent == widget + + # Reset the event log so all previous add events are lost + EventLog.reset() + + # Add the child to the widget again + widget.add(child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # The impl's add_child has *not* been invoked, + # as the widget was already a child + assert_action_not_performed(widget, "add child") + + +def test_insert_child_into_leaf(): + "A child cannot be inserted into a leaf node" + leaf = TestLeafWidget() + + # Widget doesn't have an app or window + assert leaf.app is None + assert leaf.window is None + + # Leaf nodes report an empty child list + assert leaf.children == [] + + # Create a child widget + child = TestLeafWidget() + + # insert the child. + with pytest.raises(ValueError, match=r"Cannot insert child"): + leaf.insert(0, child) + + +def test_insert_child_without_app(widget): + "A child can be inserted into a node when there's no underlying app" + # Widget doesn't have an app or window + assert widget.app is None + assert widget.window is None + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget() + + # insert the child. + widget.insert(0, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app is None + assert child.window is None + + # The impl's insert_child has been invoked + assert_action_performed_with(widget, "insert child", child=child._impl) + + +def test_insert_child(widget): + "A child can be inserted into a node when there's an app & window" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget(id="child_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child_id" not in app.widgets + + # insert the child. + widget.insert(0, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app == app + assert child.window == window + + # The impl's insert_child has been invoked + assert_action_performed_with(widget, "insert child", child=child._impl) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + # App's widget index has been updated + assert len(app.widgets) == 2 + assert app.widgets["widget_id"] == widget + assert app.widgets["child_id"] == child + + +def test_insert_position(widget): + "Insert can put a child into a specific position" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create 3 child widgets + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child1_id" not in app.widgets + assert "child2_id" not in app.widgets + assert "child3_id" not in app.widgets + + # insert the children. + widget.insert(0, child1) + widget.insert(0, child2) + widget.insert(1, child3) + + # Widget knows about the child and vice versa + assert widget.children == [child2, child3, child1] + assert child1.parent == widget + assert child2.parent == widget + assert child3.parent == widget + + # Children has inherited parent's app/window details + assert child1.app == app + assert child1.window == window + + assert child2.app == app + assert child2.window == window + + assert child3.app == app + assert child3.window == window + + # The impl's insert_child has been invoked 3 time + assert_action_performed_with(widget, "insert child", child=child1._impl) + assert_action_performed_with(widget, "insert child", child=child2._impl) + assert_action_performed_with(widget, "insert child", child=child3._impl) + + # The window layout has been refreshed on each insertion + assert window.content.refresh.mock_calls == [call()] * 3 + + # App's widget index has been updated + assert len(app.widgets) == 4 + assert app.widgets["widget_id"] == widget + assert app.widgets["child1_id"] == child1 + assert app.widgets["child2_id"] == child2 + assert app.widgets["child3_id"] == child3 + + +def test_insert_bad_position(widget): + "If the position is invalid, an error is raised" + # Set the app and window for the widget. + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + # Widget has an app and window + assert widget.app == app + assert widget.window == window + + # Child list is empty + assert widget.children == [] + + # Create a child widget + child = TestLeafWidget(id="child_id") + + # App's widget index only contains the parent + assert app.widgets["widget_id"] == widget + assert "child_id" not in app.widgets + + # Insert the child at an position greater than the length of the list. + # Widget will be added to the end of the list. + widget.insert(37, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Child has inherited parent's app/window details + assert child.app == app + assert child.window == window + + # The impl's insert_child has been invoked + assert_action_performed_with(widget, "insert child", child=child._impl) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + # App's widget index has been updated + assert len(app.widgets) == 2 + assert app.widgets["widget_id"] == widget + assert app.widgets["child_id"] == child + + +def test_insert_reparent_child(widget): + "A widget can be reparented by insertion" + # Create a second parent widget, and add a child to it + other = TestWidget(id="other") + child = TestLeafWidget(id="child_id") + other.add(child) + + assert other.children == [child] + assert child.parent == other + + # insert the child to the widget + widget.insert(0, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # Original parent as lost the child + assert other.children == [] + + # The impl's insert_child has been invoked + assert_action_performed_with(widget, "insert child", child=child._impl) + + +def test_insert_reparent_child_to_self(widget): + "Reparenting a widget to the same parent by insertion is a no-op" + # Add a child to the widget + child = TestLeafWidget(id="child_id") + widget.add(child) + + assert widget.children == [child] + assert child.parent == widget + + # Reset the event log so all previous insert events are lost + EventLog.reset() + + # insert the child to the widget again + widget.insert(0, child) + + # Widget knows about the child and vice versa + assert widget.children == [child] + assert child.parent == widget + + # The impl's insert_child has *not* been invoked, + # as the widget was already a child + assert_action_not_performed(widget, "insert child") + + +def test_remove_child_without_app(widget): + "A child without an app or window can be removed from a widget" + # Add a child to the widget + child = TestLeafWidget(id="child_id") + widget.add(child) + + assert widget.children == [child] + assert child.parent == widget + assert child.app is None + assert child.window is None + + # Remove the child + widget.remove(child) + + # Parent doesn't know about the child, and vice versa + assert widget.children == [] + assert child.parent is None + + # App and window are still None + assert child.app is None + assert child.window is None + + # The impl's remove_child has been invoked + assert_action_performed_with(widget, "remove child", child=child._impl) + + +def test_remove_child(widget): + "A child associated with an app & window can be removed from a widget" + # Add a child to the widget + child = TestLeafWidget(id="child_id") + widget.add(child) + + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + assert widget.children == [child] + assert child.parent == widget + assert child.app == app + assert child.window == window + + # Remove the child + widget.remove(child) + + # Parent doesn't know about the child, and vice versa + assert widget.children == [] + assert child.parent is None + + # app and window have been reset. + assert child.app is None + assert child.window is None + + # The impl's remove_child has been invoked + assert_action_performed_with(widget, "remove child", child=child._impl) + + # The window layout has been refreshed + window.content.refresh.assert_called_once_with() + + +def test_remove_multiple_children(widget): + "Multiple children can be removed from a widget" + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + app = toga.App("Test", "com.example.test") + window = Mock() + widget.app = app + widget.window = window + + assert widget.children == [child1, child2, child3] + for child in widget.children: + assert child.parent == widget + assert child.app == app + assert child.window == window + + # Remove 2 children + widget.remove(child1, child3) + + # Parent doesn't know about the removed children, and vice versa + assert widget.children == [child2] + assert child1.parent is None + assert child2.parent == widget + assert child3.parent is None + + # App and window have been reset on the removed widgets + assert child1.app is None + assert child1.window is None + + assert child2.app == app + assert child2.window == window + + assert child3.app is None + assert child3.window is None + + # The impl's remove_child has been invoked twice + assert_action_performed_with(widget, "remove child", child=child1._impl) + assert_action_performed_with(widget, "remove child", child=child3._impl) + + # The window layout has been refreshed once + window.content.refresh.assert_called_once_with() + + +def test_remove_from_non_parent(widget): + "Trying to remove a child from a widget other than it's parent is a no-op" + # Create a second parent widget, and add a child to it + other = TestWidget(id="other") + child = TestLeafWidget(id="child_id") + other.add(child) + + assert widget.children == [] + assert other.children == [child] + assert child.parent == other + + # Remove the child from *widget*, which is not the parent + widget.remove(child) + + # Nothing has changed. + assert widget.children == [] + assert other.children == [child] + assert child.parent == other + + # The impl's remove_child has been invoked + assert_action_not_performed(widget, "remove child") + + +def test_set_app(widget): + "A widget can be assigned to an app" + app = toga.App("Test App", "org.beeware.test") + assert len(app.widgets) == 0 + + # Assign the widget to an app + widget.app = app + + # The app has been assigned + assert widget.app == app + + # The widget index has been updated + assert len(app.widgets) == 1 + assert app.widgets["widget_id"] == widget + + # The impl has had it's app property set. + assert attribute_value(widget, "app") == app + + +def test_set_app_with_children(widget): + "If a widget has children, the children get the app assignment" + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + # Set up an app + app = toga.App("Test App", "org.beeware.test") + assert len(app.widgets) == 0 + + # Assign the widget to an app + widget.app = app + + # The app has been assigned + assert widget.app == app + + # The children also have the app assigned + assert child1.app == app + assert child2.app == app + assert child3.app == app + + # The widget index has been updated + assert len(app.widgets) == 4 + assert app.widgets["widget_id"] == widget + assert app.widgets["child1_id"] == child1 + assert app.widgets["child2_id"] == child2 + assert app.widgets["child3_id"] == child3 + + # The impl of widget and children have had their app property set. + assert attribute_value(widget, "app") == app + assert attribute_value(child1, "app") == app + assert attribute_value(child2, "app") == app + assert attribute_value(child3, "app") == app + + +def test_set_same_app(widget): + "A widget can be re-assigned to the same app" + app = toga.App("Test App", "org.beeware.test") + assert len(app.widgets) == 0 + + # Assign the widget to an app + widget.app = app + + # Reset the event log so we know the new events + EventLog.reset() + + # Assign the widget to the same app + widget.app = app + + # The impl has not had it's app property set as a result of the update + assert_attribute_not_set(widget, "app") + + +def test_reset_app(widget): + "A widget can be re-assigned to no app" + app = toga.App("Test App", "org.beeware.test") + assert len(app.widgets) == 0 + + # Assign the widget to an app + widget.app = app + + # Reset the event log so we know the new events + EventLog.reset() + + # Clear the app assignment + widget.app = None + + # The app has been assigned + assert widget.app is None + + # The widget index has been updated + assert len(app.widgets) == 0 + + # The impl has had it's app property set. + assert attribute_value(widget, "app") is None + + +def test_set_new_app(widget): + "A widget can be assigned to a different app" + app = toga.App("Test App", "org.beeware.test") + + # Assign the widget to an app + widget.app = app + assert len(app.widgets) == 1 + + # Reset the event log so we know the new events + EventLog.reset() + + # Create a new app + new_app = toga.App("Test App", "org.beeware.test") + assert len(new_app.widgets) == 0 + + # Assign the widget to the same app + widget.app = new_app + + # The widget has been assigned to the new app + assert widget.app == new_app + + # The widget indices has been updated + assert len(app.widgets) == 0 + assert len(new_app.widgets) == 1 + assert new_app.widgets["widget_id"] == widget + + # The impl has had it's app property set. + assert attribute_value(widget, "app") == new_app + + +def test_set_window(widget): + "A widget can be assigned to a window." + window = toga.Window() + assert len(window.widgets) == 0 + assert widget.window is None + + # Assign the widget to a window + widget.window = window + + # Window has been assigned + assert widget.window == window + + # Window Widget registry has been updated + assert len(window.widgets) == 1 + assert window.widgets["widget_id"] == widget + + +def test_set_window_with_children(widget): + "A widget can be assigned to a window." + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + window = toga.Window() + assert len(window.widgets) == 0 + assert widget.window is None + assert child1.window is None + assert child2.window is None + assert child3.window is None + + # Assign the widget to a window + widget.window = window + + # Window has been assigned + assert widget.window == window + assert child1.window == window + assert child2.window == window + assert child3.window == window + + # Window Widget registry has been updated + assert len(window.widgets) == 4 + assert window.widgets["widget_id"] == widget + assert window.widgets["child1_id"] == child1 + assert window.widgets["child2_id"] == child2 + assert window.widgets["child3_id"] == child3 + + +def test_reset_window(widget): + "A widget can be assigned to a different window." + window = toga.Window() + assert len(window.widgets) == 0 + assert widget.window is None + + # Assign the widget to a window + widget.window = window + assert len(window.widgets) == 1 + + # Create a new window + new_window = toga.Window() + + # Assign the widget to the new window + widget.window = new_window + + # Window has been assigned + assert widget.window == new_window + + # Window Widget registry has been updated + assert len(window.widgets) == 0 + assert len(new_window.widgets) == 1 + assert new_window.widgets["widget_id"] == widget + + +def test_unset_window(widget): + "A widget can be assigned to no window." + window = toga.Window() + assert len(window.widgets) == 0 + assert widget.window is None + + # Assign the widget to a window + widget.window = window + assert len(window.widgets) == 1 + + # Assign the widget to no window + widget.window = None + + # The widget doesn't have a window + assert widget.window is None + + # Window Widget registry has been updated + assert len(window.widgets) == 0 + + +@pytest.mark.parametrize( + "value, expected", + [ + (None, False), + ("", False), + ("true", True), + ("false", True), # Evaluated as a string, this value is true. + (0, False), + (1234, True), + ], +) +def test_enabled(widget, value, expected): + "The enabled status of the widget can be changed." + # Widget is initially enabled by default. + assert widget.enabled + + # Set the enabled status + widget.enabled = value + assert widget.enabled == expected + + # Disable the widget + widget.enabled = False + assert not widget.enabled + + # Set the enabled status again + widget.enabled = value + assert widget.enabled == expected + + +def test_refresh_root(widget): + "Refresh can be invoked on the root node" + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + # Refresh the root node + widget.refresh() + + # Root widget was refreshed + assert_action_performed(widget, "refresh") + + +def test_refresh_child(widget): + "Refresh can be invoked on child" + # Add children to the widget + child1 = TestLeafWidget(id="child1_id") + child2 = TestLeafWidget(id="child2_id") + child3 = TestLeafWidget(id="child3_id") + widget.add(child1, child2, child3) + + # Refresh a child + child2.refresh() + + # Child widget was refreshed + assert_action_performed(child2, "refresh") + + # Root widget was refreshed + assert_action_performed(widget, "refresh") + + +def test_focus(widget): + "A widget can be given focus" + widget.focus() + assert_action_performed(widget, "focus") + + +def test_tab_index(widget): + "The tab index of a widget can be set and retrieved" + # The initial tab index is None + assert widget.tab_index is None - def test_focus(self): - self.widget.focus() - self.assertActionPerformed(self.widget, "focus") + tab_index = 8 + widget.tab_index = tab_index - def test_set_tab_index(self): - tab_index = 8 - self.widget.tab_index = tab_index - self.assertValueSet(self.widget, "tab_index", tab_index) - - def test_get_tab_index(self): - tab_index = 8 - self.widget.tab_index = tab_index - self.assertEqual(self.widget.tab_index, tab_index) - self.assertValueGet(self.widget, "tab_index") + assert widget.tab_index == 8 + assert attribute_value(widget, "tab_index") == tab_index diff --git a/core/tests/widgets/test_box.py b/core/tests/widgets/test_box.py index 36b948cc94..2a9f5d1ad1 100644 --- a/core/tests/widgets/test_box.py +++ b/core/tests/widgets/test_box.py @@ -2,6 +2,7 @@ from toga_dummy.utils import ( assert_action_not_performed, assert_action_performed, + assert_action_performed_with, ) @@ -25,9 +26,8 @@ def test_create_box_with_children(): assert box._impl.interface == box assert_action_performed(box, "create Box") - # The impl-level add-child will not be called, - # because the box hasn't been assigned to a window - assert_action_not_performed(box, "add child") + assert_action_performed_with(box, "add child", child=child1._impl) + assert_action_performed_with(box, "add child", child=child2._impl) # But the box will have children. assert box.children == [child1, child2] @@ -45,3 +45,11 @@ def test_disable_no_op(): # Still enabled. assert box.enabled + + +def test_focus_noop(): + "Focus is a no-op." + box = toga.Box() + + box.focus() + assert_action_not_performed(box, "focus") diff --git a/core/tests/widgets/test_divider.py b/core/tests/widgets/test_divider.py index f8e2d6bd0b..12e02acb9c 100644 --- a/core/tests/widgets/test_divider.py +++ b/core/tests/widgets/test_divider.py @@ -3,6 +3,7 @@ import toga from toga_dummy.utils import ( EventLog, + assert_action_not_performed, assert_action_performed, ) @@ -62,3 +63,11 @@ def test_update_direction(): # The direction has been changed, and a refresh requested assert divider.direction == toga.Divider.VERTICAL assert_action_performed(divider, "refresh") + + +def test_focus_noop(): + "Focus is a no-op." + divider = toga.Divider(direction=toga.Divider.HORIZONTAL) + + divider.focus() + assert_action_not_performed(divider, "focus") diff --git a/core/tests/widgets/test_label.py b/core/tests/widgets/test_label.py index 81e3eedf02..cb77bba260 100644 --- a/core/tests/widgets/test_label.py +++ b/core/tests/widgets/test_label.py @@ -3,6 +3,7 @@ import toga from toga_dummy.utils import ( EventLog, + assert_action_not_performed, assert_action_performed, attribute_value, ) @@ -44,3 +45,10 @@ def test_update_label_text(label, value, expected): # A rehint was performed assert_action_performed(label, "refresh") + + +def test_focus_noop(label): + "Focus is a no-op." + + label.focus() + assert_action_not_performed(label, "focus") diff --git a/core/tests/widgets/test_progressbar.py b/core/tests/widgets/test_progressbar.py index 8c25b7f191..ee9f9dceab 100644 --- a/core/tests/widgets/test_progressbar.py +++ b/core/tests/widgets/test_progressbar.py @@ -239,3 +239,15 @@ def test_determinate_switch(progressbar): # State of progress bar is determinate assert progressbar.is_determinate assert progressbar.value == pytest.approx(5.0) + + +def test_disable_no_op(progressbar): + """ProgressBar doesn't have a disabled state""" + # Enabled by default + assert progressbar.enabled + + # Try to disable the widget + progressbar.enabled = False + + # Still enabled. + assert progressbar.enabled diff --git a/docs/reference/api/widgets/widget.rst b/docs/reference/api/widgets/widget.rst index 6dca67fcc3..6c5b4ec889 100644 --- a/docs/reference/api/widgets/widget.rst +++ b/docs/reference/api/widgets/widget.rst @@ -1,6 +1,8 @@ Widget ====== +The abstract base class of all widgets. This class should not be be instantiated directly. + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,7 +10,6 @@ Widget :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!^(Widget|Component)$)'} -The base class of all widgets. This class should not be be instantiated directly. Reference --------- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 5323583e56..f72ca5f5cd 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -22,7 +22,7 @@ TextInput,General Widget,:class:`~toga.widgets.textinput.TextInput`,Text Input f TimePicker,General Widget,:class:`~toga.widgets.timepicker.TimePicker`,An input for times,,,|b|,,|b|, Tree,General Widget,:class:`~toga.widgets.tree.Tree`,Tree of data,|b|,|b|,|b|,,, WebView,General Widget,:class:`~toga.widgets.webview.WebView`,A panel for displaying HTML,|b|,|b|,|b|,|b|,|b|, -Widget,General Widget,:class:`~toga.widgets.base.Widget`,The base widget,|b|,|b|,|b|,|b|,|b|,|b| +Widget,General Widget,:class:`~toga.widgets.base.Widget`,The base widget,|y|,|y|,|y|,|y|,|y|,|b| Box,Layout Widget,:class:`~toga.widgets.box.Box`,Container for components,|y|,|y|,|y|,|y|,|y|,|b| ScrollContainer,Layout Widget,:class:`~toga.widgets.scrollcontainer.ScrollContainer`,Scrollable Container,|b|,|b|,|b|,|b|,|b|, SplitContainer,Layout Widget,:class:`~toga.widgets.splitcontainer.SplitContainer`,Split Container,|b|,|b|,|b|,,, diff --git a/docs/reference/style/pack.rst b/docs/reference/style/pack.rst index 7e88c02843..06505795c3 100644 --- a/docs/reference/style/pack.rst +++ b/docs/reference/style/pack.rst @@ -38,13 +38,19 @@ visible. ``visibility`` -------------- -**Values:** ``hidden`` | ``visible`` | ``none`` +**Values:** ``hidden`` | ``visible`` **Initial value:** ``visible`` -Used to define whether the element should be drawn. A value of ``visible`` -means the element will be displayed. A value of ``none`` removes the element, -but still allocates space for the element as if it were in the element tree. +Used to define whether the element should be drawn. A value of ``visible`` means +the element will be displayed. A value of ``hidden`` removes the element from +view, but allocates space for the element as if it were still in the layout. + +Any children of a hidden element are implicitly removed from view. + +If a previously hidden element is made visible, any children of the element with +a visibility of ``hidden`` will remain hidden. Any descendants of the hidden +child will also remain hidden, regardless of their visibility. ``direction`` ------------- diff --git a/dummy/src/toga_dummy/utils.py b/dummy/src/toga_dummy/utils.py index 728567d416..ec1f391b32 100644 --- a/dummy/src/toga_dummy/utils.py +++ b/dummy/src/toga_dummy/utils.py @@ -397,7 +397,8 @@ def assert_action_performed_with(_widget, _action, **test_data): try: # Iterate over every action that was performed on # this object. - for data in EventLog.performed_actions(_widget, _action): + actions_performed = EventLog.performed_actions(_widget, _action) + for data in actions_performed: found = True # Iterate over every key and value in the test # data. If the value in the recorded action @@ -428,6 +429,13 @@ def assert_action_performed_with(_widget, _action, **test_data): # with the next recorded action. if found: return + + # None of the recorded actions match the test data. + pytest.fail( + f"Widget {_widget} did not perform action {_action!r} " + f"with data {test_data!r}; " + f"actions performed were {actions_performed!r}" + ) except AttributeError as e: # None of the recorded actions match the test data. pytest.fail(str(e)) diff --git a/dummy/src/toga_dummy/widgets/base.py b/dummy/src/toga_dummy/widgets/base.py index 6e57263cb6..3bfd3451b8 100644 --- a/dummy/src/toga_dummy/widgets/base.py +++ b/dummy/src/toga_dummy/widgets/base.py @@ -1,6 +1,7 @@ -from ..utils import LoggedObject, not_required_on +from ..utils import LoggedObject, not_required +@not_required # Testbed coverage is complete for this widget. class Widget(LoggedObject): def __init__(self, interface): super().__init__() @@ -10,7 +11,7 @@ def __init__(self, interface): self.create() def create(self): - pass + self._action("create Widget") def set_app(self, app): self._set_value("app", app) @@ -18,14 +19,6 @@ def set_app(self, app): def set_window(self, window): self._set_value("window", window) - @property - def container(self): - return self._get_value("container") - - @container.setter - def container(self, container): - self._set_value("container", container) - def get_enabled(self): return self._get_value("enabled", True) @@ -36,10 +29,10 @@ def focus(self): self._action("focus") def get_tab_index(self): - return self._get_value("tab_index") + return self._get_value("tab_index", None) def set_tab_index(self, tab_index): - return self._set_value("tab_index", tab_index) + self._set_value("tab_index", tab_index) ###################################################################### # APPLICATOR @@ -48,12 +41,18 @@ def set_tab_index(self, tab_index): def set_bounds(self, x, y, width, height): self._action("set bounds", x=x, y=y, width=width, height=height) + def set_alignment(self, alignment): + self._action("set alignment", alignment=alignment) + def set_hidden(self, hidden): self._action("set hidden", hidden=hidden) def set_font(self, font): self._action("set font", font=font) + def set_color(self, color): + self._action("set color", color=color) + def set_background_color(self, color): self._action("set background color", color=color) @@ -70,9 +69,5 @@ def insert_child(self, index, child): def remove_child(self, child): self._action("remove child", child=child) - @not_required_on("gtk", "winforms", "android", "web") - def add_constraints(self): - self._action("add constraints") - def refresh(self): self._action("refresh") diff --git a/dummy/src/toga_dummy/widgets/label.py b/dummy/src/toga_dummy/widgets/label.py index a7e1f02dc7..137857b124 100644 --- a/dummy/src/toga_dummy/widgets/label.py +++ b/dummy/src/toga_dummy/widgets/label.py @@ -7,9 +7,6 @@ class Label(Widget): def create(self): self._action("create Label") - def set_alignment(self, value): - self._set_value("alignment", value) - def get_text(self): return self._get_value("text") diff --git a/dummy/src/toga_dummy/widgets/progressbar.py b/dummy/src/toga_dummy/widgets/progressbar.py index cea8c52ccf..ef2aa59f83 100644 --- a/dummy/src/toga_dummy/widgets/progressbar.py +++ b/dummy/src/toga_dummy/widgets/progressbar.py @@ -1,6 +1,8 @@ +from ..utils import not_required from .base import Widget +@not_required # Testbed coverage is complete for this widget. class ProgressBar(Widget): def create(self): self._action("create ProgressBar") diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 7c83611139..2afe2a1270 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -34,14 +34,6 @@ def create(self): icon_impl = toga_App.app.icon._impl self.native.set_icon(icon_impl.native_72.get_pixbuf()) - def set_app(self, app): - super().set_app(app) - - # The GTK docs list set_wmclass() as deprecated (and "pointless") - # but it's the only way I've found that actually sets the - # Application name to something other than '__main__.py'. - self.native.set_wmclass(app.interface.name, app.interface.name) - def gtk_delete_event(self, *args): # Return value of the GTK on_close handler indicates # whether the event has been fully handled. Returning @@ -73,7 +65,8 @@ def __init__(self, interface): def create(self): # Stimulate the build of the app self.native = Gtk.Application( - application_id=self.interface.app_id, flags=Gio.ApplicationFlags.FLAGS_NONE + application_id=self.interface.app_id, + flags=Gio.ApplicationFlags.FLAGS_NONE, ) # Connect the GTK signal that will cause app startup to occur diff --git a/gtk/src/toga_gtk/widgets/base.py b/gtk/src/toga_gtk/widgets/base.py index a4867ad31b..dffa47b4cd 100644 --- a/gtk/src/toga_gtk/widgets/base.py +++ b/gtk/src/toga_gtk/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from travertino.size import at_least from ..libs import Gtk, get_background_color_css, get_color_css, get_font_css @@ -30,8 +32,9 @@ def viewport(self): # TODO: Remove the use of viewport return self._container + @abstractmethod def create(self): - pass + ... def set_app(self, app): pass @@ -46,17 +49,16 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # container is set to None, removing self from the container.native - # Note from pygtk documentation: Note that the container will own a - # reference to widget, and that this may be the last reference held; - # so removing a widget from its container can cause that widget to be - # destroyed. If you want to use widget again, you should add a - # reference to it. - self._container.remove(self.native) - self._container = None + assert container is None, "Widget Already have a container" + + # container is set to None, removing self from the container.native + # Note from pygtk documentation: Note that the container will own a + # reference to widget, and that this may be the last reference held; + # so removing a widget from its container can cause that widget to be + # destroyed. If you want to use widget again, you should add a + # reference to it. + self._container.remove(self.native) + self._container = None elif container: # setting container, adding self to container.native self._container = container @@ -78,10 +80,10 @@ def focus(self): self.native.grab_focus() def get_tab_index(self): - self.interface.factory.not_implementated("Widget.get_tab_index()") + self.interface.factory.not_implemented("Widget.get_tab_index()") def set_tab_index(self, tab_index): - self.interface.factory.not_implementated("Widget.set_tab_index()") + self.interface.factory.not_implemented("Widget.set_tab_index()") ###################################################################### # CSS tools @@ -142,15 +144,14 @@ def apply_css(self, property, css, native=None): def set_bounds(self, x, y, width, height): # Any position changes are applied by the container during do_size_allocate. - if self.container: - self.container.make_dirty() + self.container.make_dirty() def set_alignment(self, alignment): # By default, alignment can't be changed pass def set_hidden(self, hidden): - return not self.native.set_visible(not hidden) + self.native.set_visible(not hidden) def set_color(self, color): self.apply_css("color", get_color_css(color)) diff --git a/gtk/src/toga_gtk/widgets/detailedlist.py b/gtk/src/toga_gtk/widgets/detailedlist.py index 9104795829..04945d53b8 100644 --- a/gtk/src/toga_gtk/widgets/detailedlist.py +++ b/gtk/src/toga_gtk/widgets/detailedlist.py @@ -32,8 +32,8 @@ def create(self): self.scrolled_window = Gtk.ScrolledWindow() self.scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - self.scrolled_window.set_min_content_width(self.interface.MIN_WIDTH) - self.scrolled_window.set_min_content_height(self.interface.MIN_HEIGHT) + self.scrolled_window.set_min_content_width(self.interface._MIN_WIDTH) + self.scrolled_window.set_min_content_height(self.interface._MIN_HEIGHT) self.scrolled_window.add(self.list_box) diff --git a/gtk/src/toga_gtk/widgets/multilinetextinput.py b/gtk/src/toga_gtk/widgets/multilinetextinput.py index 422d796ee1..9cd3e11386 100644 --- a/gtk/src/toga_gtk/widgets/multilinetextinput.py +++ b/gtk/src/toga_gtk/widgets/multilinetextinput.py @@ -68,8 +68,8 @@ def gtk_on_focus_out(self, *args): return False def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def set_on_change(self, handler): self.interface.factory.not_implemented("MultilineTextInput.set_on_change()") diff --git a/gtk/src/toga_gtk/widgets/numberinput.py b/gtk/src/toga_gtk/widgets/numberinput.py index 04697824bc..0988de1568 100644 --- a/gtk/src/toga_gtk/widgets/numberinput.py +++ b/gtk/src/toga_gtk/widgets/numberinput.py @@ -62,7 +62,7 @@ def rehint(self): width = self.native.get_preferred_width() height = self.native.get_preferred_height() if width and height: - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = height[1] def set_on_change(self, handler): diff --git a/gtk/src/toga_gtk/widgets/scrollcontainer.py b/gtk/src/toga_gtk/widgets/scrollcontainer.py index 0cad6c400f..6e468e60ad 100644 --- a/gtk/src/toga_gtk/widgets/scrollcontainer.py +++ b/gtk/src/toga_gtk/widgets/scrollcontainer.py @@ -9,8 +9,8 @@ def create(self): # Set this minimum size of scroll windows because we must reserve space for # scrollbars when splitter resized. See, https://gitlab.gnome.org/GNOME/gtk/-/issues/210 - self.native.set_min_content_width(self.interface.MIN_WIDTH) - self.native.set_min_content_height(self.interface.MIN_HEIGHT) + self.native.set_min_content_width(self.interface._MIN_WIDTH) + self.native.set_min_content_height(self.interface._MIN_HEIGHT) self.native.set_overlay_scrolling(True) diff --git a/gtk/src/toga_gtk/widgets/selection.py b/gtk/src/toga_gtk/widgets/selection.py index 27c1822aa2..26e8ce42d2 100644 --- a/gtk/src/toga_gtk/widgets/selection.py +++ b/gtk/src/toga_gtk/widgets/selection.py @@ -33,7 +33,7 @@ def rehint(self): # width = self.native.get_preferred_width() height = self.native.get_preferred_height() - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = height[1] def set_on_select(self, handler): diff --git a/gtk/src/toga_gtk/widgets/textinput.py b/gtk/src/toga_gtk/widgets/textinput.py index 0192719deb..77f1f82bfc 100644 --- a/gtk/src/toga_gtk/widgets/textinput.py +++ b/gtk/src/toga_gtk/widgets/textinput.py @@ -53,7 +53,7 @@ def rehint(self): # width = self.native.get_preferred_width() height = self.native.get_preferred_height() - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = height[1] def set_on_change(self, handler): diff --git a/gtk/src/toga_gtk/widgets/webview.py b/gtk/src/toga_gtk/widgets/webview.py index 9fc0b25d95..f73c93465d 100644 --- a/gtk/src/toga_gtk/widgets/webview.py +++ b/gtk/src/toga_gtk/widgets/webview.py @@ -96,5 +96,5 @@ def invoke_javascript(self, javascript): self.native.run_javascript(javascript, None, None) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 1ca10d9a0b..e298a8b6a1 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -1,5 +1,7 @@ import asyncio +import pytest + from toga_gtk.libs import Gtk from .properties import toga_color, toga_font @@ -24,6 +26,10 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.get_parent() is None + def assert_alignment(self, expected): assert self.alignment == expected @@ -52,6 +58,24 @@ def width(self): def height(self): return self.native.get_allocation().height + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.get_parent() is not None + + # Measurements are relative to the container as an origin. + origin = self.widget._impl.container.get_allocation() + + # size and position is as expected. + assert ( + self.native.get_allocation().width, + self.native.get_allocation().height, + ) == size + assert ( + self.native.get_allocation().x - origin.x, + self.native.get_allocation().y - origin.y, + ) == position + def assert_width(self, min_width, max_width): assert ( min_width <= self.width <= max_width @@ -76,3 +100,14 @@ def background_color(self): def font(self): sc = self.native.get_style_context() return toga_font(sc.get_property("font", sc.get_state())) + + @property + def is_hidden(self): + return not self.native.get_visible() + + @property + def has_focus(self): + # FIXME: This works when running standalone, but fails under CI. + # I *think* this is because CI is using xvfb. + # return self.native.has_focus() + pytest.skip("Focus changes don't work on GTK inside XVFB") diff --git a/gtk/tests_backend/widgets/textinput.py b/gtk/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..d2a347fe29 --- /dev/null +++ b/gtk/tests_backend/widgets/textinput.py @@ -0,0 +1,7 @@ +from toga_gtk.libs import Gtk + +from .base import SimpleProbe + + +class TextInputProbe(SimpleProbe): + native_class = Gtk.Entry diff --git a/iOS/src/toga_iOS/constraints.py b/iOS/src/toga_iOS/constraints.py index 9f9515efa0..144067cbed 100644 --- a/iOS/src/toga_iOS/constraints.py +++ b/iOS/src/toga_iOS/constraints.py @@ -25,22 +25,37 @@ def __init__(self, widget): self.left_constraint = None self.top_constraint = None + # Deletion isn't an event we can programatically invoke; deletion + # of constraints can take several iterations before it occurs. + def __del__(self): # pragma: nocover + self._remove_constraints() + + def _remove_constraints(self): + if self.container: + # print(f"Remove constraints for {self.widget} in {self.container}") + self.container.native.removeConstraint(self.width_constraint) + self.container.native.removeConstraint(self.height_constraint) + self.container.native.removeConstraint(self.left_constraint) + self.container.native.removeConstraint(self.top_constraint) + + self.width_constraint.release() + self.height_constraint.release() + self.left_constraint.release() + self.top_constraint.release() + @property def container(self): return self._container @container.setter def container(self, value): - if value is None and self.container: - # print("Remove constraints") - self.container.native.removeConstraint(self.width_constraint) - self.container.native.removeConstraint(self.height_constraint) - self.container.native.removeConstraint(self.left_constraint) - self.container.native.removeConstraint(self.top_constraint) - self._container = value - else: - self._container = value - # print("Add constraints for", self.widget, "in", self.container, self.widget.interface.layout) + # This will *always* remove and then add constraints. It relies on the base widget to + # *not* invoke this setter unless the container is actually changing. + + self._remove_constraints() + self._container = value + if value is not None: + # print(f"Add constraints for {self.widget} in {self.container} {self.widget.interface.layout}") self.left_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 self.widget.native, NSLayoutAttributeLeft, @@ -49,7 +64,7 @@ def container(self, value): NSLayoutAttributeLeft, 1.0, 10, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.left_constraint) self.top_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 @@ -60,7 +75,7 @@ def container(self, value): NSLayoutAttributeTop, 1.0, 5, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.top_constraint) self.width_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 @@ -71,7 +86,7 @@ def container(self, value): NSLayoutAttributeLeft, 1.0, 50, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.width_constraint) self.height_constraint = NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( # noqa: E501 @@ -82,12 +97,12 @@ def container(self, value): NSLayoutAttributeTop, 1.0, 30, # Use a dummy, non-zero value for now - ) + ).retain() self.container.native.addConstraint(self.height_constraint) def update(self, x, y, width, height): if self.container: - # print(f"UPDATE CONSTRAINTS {self.widget} {width}x{height}@{x},{y}") + # print(f"UPDATE CONSTRAINTS {self.widget} in {self.container} {width}x{height}@{x},{y}") self.left_constraint.constant = x self.top_constraint.constant = y diff --git a/iOS/src/toga_iOS/widgets/base.py b/iOS/src/toga_iOS/widgets/base.py index 3d90e677e7..d4fb372583 100644 --- a/iOS/src/toga_iOS/widgets/base.py +++ b/iOS/src/toga_iOS/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from toga.colors import TRANSPARENT from toga_iOS.colors import native_color from toga_iOS.constraints import Constraints @@ -16,8 +18,9 @@ def __init__(self, interface): self.create() self.interface.style.reapply() + @abstractmethod def create(self): - pass + ... def set_app(self, app): pass @@ -32,19 +35,16 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # existing container should be removed - self.constraints = None - self._container = None - self.native.removeFromSuperview() + assert container is None, "Widget already has a container" + + # Existing container should be removed + self.constraints.container = None + self._container = None + self.native.removeFromSuperview() elif container: # setting container self._container = container self._container.native.addSubview(self.native) - if not self.constraints: - self.add_constraints() self.constraints.container = container for child in self.interface.children: @@ -67,17 +67,18 @@ def set_enabled(self, value): self.native.enabled = value def focus(self): - self.interface.factory.not_implemented("Widget.focus()") + self.native.becomeFirstResponder() def get_tab_index(self): - self.interface.factory.not_implementated("Widget.get_tab_index()") + self.interface.factory.not_implemented("Widget.get_tab_index()") def set_tab_index(self, tab_index): - self.interface.factory.not_implementated("Widget.set_tab_index()") + self.interface.factory.not_implemented("Widget.set_tab_index()") # APPLICATOR def set_bounds(self, x, y, width, height): + # print("SET BOUNDS", self, x, y, width, height, self.constraints) offset_y = 0 if self.container: offset_y = self.container.viewport.top_offset @@ -89,10 +90,7 @@ def set_alignment(self, alignment): pass def set_hidden(self, hidden): - if self.container: - for view in self.container._impl.subviews: - if view._impl: - view.setHidden(hidden) + self.native.setHidden(hidden) def set_font(self, font): # By default, font can't be changed @@ -112,8 +110,10 @@ def set_background_color_simple(self, value): self.native.backgroundColor = native_color(value) else: try: - self.native.backgroundColor = UIColor.systemBackgroundColor() # iOS 13+ - except AttributeError: + # systemBackgroundColor() was introduced in iOS 13 + # We don't test on iOS 12, so mark the other branch as nocover + self.native.backgroundColor = UIColor.systemBackgroundColor() + except AttributeError: # pragma: no cover self.native.backgroundColor = UIColor.whiteColor # INTERFACE @@ -138,5 +138,6 @@ def add_constraints(self): def refresh(self): self.rehint() + @abstractmethod def rehint(self): - pass + ... diff --git a/iOS/src/toga_iOS/widgets/detailedlist.py b/iOS/src/toga_iOS/widgets/detailedlist.py index 9b6fcf3eaa..5706ef0016 100644 --- a/iOS/src/toga_iOS/widgets/detailedlist.py +++ b/iOS/src/toga_iOS/widgets/detailedlist.py @@ -1,4 +1,5 @@ from rubicon.objc import SEL, objc_method, objc_property +from travertino.size import at_least from toga_iOS.libs import ( NSIndexPath, @@ -151,3 +152,7 @@ def scroll_to_row(self, row): atScrollPosition=UITableViewScrollPositionNone, animated=False, ) + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/iOS/src/toga_iOS/widgets/imageview.py b/iOS/src/toga_iOS/widgets/imageview.py index 32cb36677b..6cb5286e36 100644 --- a/iOS/src/toga_iOS/widgets/imageview.py +++ b/iOS/src/toga_iOS/widgets/imageview.py @@ -1,3 +1,5 @@ +from travertino.size import at_least + from toga_iOS.libs import UIImageView, UIViewContentMode from toga_iOS.widgets.base import Widget @@ -19,3 +21,7 @@ def set_image(self, image): self.native.image = image._impl.native else: self.native.image = None + + def rehint(self): + self.interface.intrinsic.width = at_least(0) + self.interface.intrinsic.height = at_least(0) diff --git a/iOS/src/toga_iOS/widgets/label.py b/iOS/src/toga_iOS/widgets/label.py index ad4674bfba..97d74eac9e 100644 --- a/iOS/src/toga_iOS/widgets/label.py +++ b/iOS/src/toga_iOS/widgets/label.py @@ -53,11 +53,9 @@ def rehint(self): # fit. To avoid this, temporarily relax the width and height constraint # on the widget to "effectively infinite" values; they will be # re-applied as part of the application of the newly hinted layout. - if self.constraints: - if self.constraints.width_constraint: - self.constraints.width_constraint.constant = 100000 - if self.constraints.height_constraint: - self.constraints.height_constraint.constant = 100000 + if self.constraints.container: + self.constraints.width_constraint.constant = 100000 + self.constraints.height_constraint.constant = 100000 fitting_size = self.native.systemLayoutSizeFittingSize(CGSize(0, 0)) # print(f"REHINT label {self} {self.get_text()!r} {fitting_size.width} {fitting_size.height}") self.interface.intrinsic.width = at_least(ceil(fitting_size.width)) diff --git a/iOS/src/toga_iOS/widgets/multilinetextinput.py b/iOS/src/toga_iOS/widgets/multilinetextinput.py index e12d78161a..88063c8f68 100644 --- a/iOS/src/toga_iOS/widgets/multilinetextinput.py +++ b/iOS/src/toga_iOS/widgets/multilinetextinput.py @@ -120,8 +120,8 @@ def get_value(self): return self.native.text def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def set_font(self, font): if font: diff --git a/iOS/src/toga_iOS/widgets/navigationview.py b/iOS/src/toga_iOS/widgets/navigationview.py index c223ddbfe9..3884ccad43 100644 --- a/iOS/src/toga_iOS/widgets/navigationview.py +++ b/iOS/src/toga_iOS/widgets/navigationview.py @@ -1,4 +1,5 @@ from rubicon.objc import SEL, objc_method, objc_property +from travertino.size import at_least from toga.interface import NavigationView as NavigationViewInterface from toga_iOS.libs import ( @@ -63,3 +64,7 @@ def push(self, content): def pop(self, content): self._controller.popViewController_animated_(True) + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/iOS/src/toga_iOS/widgets/scrollcontainer.py b/iOS/src/toga_iOS/widgets/scrollcontainer.py index 27634ed140..ecadc7e343 100644 --- a/iOS/src/toga_iOS/widgets/scrollcontainer.py +++ b/iOS/src/toga_iOS/widgets/scrollcontainer.py @@ -1,4 +1,5 @@ from rubicon.objc import CGSizeMake +from travertino.size import at_least from toga_iOS.libs import ( NSLayoutAttributeBottom, @@ -124,6 +125,9 @@ def rehint(self): if self.interface.content: self.update_content_size() + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + def set_on_scroll(self, on_scroll): self.interface.factory.not_implemented("ScrollContainer.set_on_scroll()") diff --git a/iOS/src/toga_iOS/widgets/slider.py b/iOS/src/toga_iOS/widgets/slider.py index 8fe076360d..e124968e44 100644 --- a/iOS/src/toga_iOS/widgets/slider.py +++ b/iOS/src/toga_iOS/widgets/slider.py @@ -96,6 +96,6 @@ def set_tick_count(self, tick_count): self.tick_count = tick_count def rehint(self): - fitting_size = self.native.systemLayoutSizeFittingSize_(CGSize(0, 0)) + fitting_size = self.native.systemLayoutSizeFittingSize(CGSize(0, 0)) self.interface.intrinsic.width = at_least(fitting_size.width) self.interface.intrinsic.height = fitting_size.height diff --git a/iOS/src/toga_iOS/widgets/webview.py b/iOS/src/toga_iOS/widgets/webview.py index a793cd96d9..b89032a21d 100644 --- a/iOS/src/toga_iOS/widgets/webview.py +++ b/iOS/src/toga_iOS/widgets/webview.py @@ -2,6 +2,7 @@ from rubicon.objc import objc_method, objc_property, py_from_ns from rubicon.objc.runtime import objc_id +from travertino.size import at_least from toga_iOS.libs import NSURL, NSURLRequest, WKWebView from toga_iOS.widgets.base import Widget @@ -90,3 +91,7 @@ def completion_handler(res: objc_id, error: objc_id) -> None: def invoke_javascript(self, javascript): self.native.evaluateJavaScript(javascript, completionHandler=None) + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index acd535c206..ed5ed4a14f 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -2,7 +2,7 @@ from toga.colors import TRANSPARENT from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM -from toga_iOS.libs import NSRunLoop, UIColor +from toga_iOS.libs import NSRunLoop, UIApplication, UIColor from .properties import toga_color @@ -48,6 +48,10 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.superview() is None + def assert_alignment(self, expected): assert self.alignment == expected @@ -90,6 +94,24 @@ def width(self): def height(self): return self.native.frame.size.height + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.superview() is not None + + # size and position is as expected. + assert (self.native.frame.size.width, self.native.frame.size.height) == size + + # Allow for the status bar and navigation bar in vertical position + statusbar_frame = UIApplication.sharedApplication.statusBarFrame + navbar = self.widget.window._impl.controller.navigationController + navbar_frame = navbar.navigationBar.frame + offset = statusbar_frame.size.height + navbar_frame.size.height + assert ( + self.native.frame.origin.x, + self.native.frame.origin.y - offset, + ) == position + def assert_width(self, min_width, max_width): assert ( min_width <= self.width <= max_width @@ -109,3 +131,11 @@ def background_color(self): async def press(self): self.native.sendActionsForControlEvents(UIControlEventTouchDown) + + @property + def is_hidden(self): + return self.native.isHidden() + + @property + def has_focus(self): + return self.native.isFirstResponder diff --git a/iOS/tests_backend/widgets/textinput.py b/iOS/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..d9efc7e3aa --- /dev/null +++ b/iOS/tests_backend/widgets/textinput.py @@ -0,0 +1,7 @@ +from toga_iOS.libs import UITextField + +from .base import SimpleProbe + + +class TextInputProbe(SimpleProbe): + native_class = UITextField diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 599fea2f2e..ffbea6896d 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -55,6 +55,12 @@ def run_tests(app, cov, args, report_coverage, run_slow): if app.returncode == 0: cov.stop() if report_coverage: + # Exclude some patterns of lines that can't have coverage + cov.exclude("pragma: no cover") + cov.exclude("@(abc\\.)?abstractmethod") + cov.exclude("NotImplementedError") + cov.exclude("\\.not_implemented\\(") + total = cov.report( precision=1, skip_covered=True, diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py index d4c3a39a9f..b29c575fd3 100644 --- a/testbed/tests/widgets/conftest.py +++ b/testbed/tests/widgets/conftest.py @@ -12,13 +12,14 @@ async def widget(): @fixture async def probe(main_window, widget): + old_content = main_window.content box = toga.Box(children=[widget]) main_window.content = box - probe = get_probe(widget) await probe.redraw() + probe.assert_container(box) yield probe - main_window.content = toga.Box() + main_window.content = old_content diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index 2647f5e019..e4e12c6471 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -1,11 +1,13 @@ from pytest import approx +import toga from toga.colors import RED, TRANSPARENT, color as named_color from toga.fonts import BOLD, FANTASY, ITALIC, NORMAL, SERIF, SYSTEM from toga.style.pack import COLUMN from ..assertions import assert_color from ..data import COLORS, TEXTS +from .probe import get_probe # An upper bound for widths MAX_WIDTH = 2000 @@ -45,6 +47,46 @@ async def test_enable_noop(widget, probe): assert widget.enabled +# async def test_hidden(widget, probe): + + +async def test_focus(widget, probe): + "The widget can be given focus" + # Add a separate widget that can take take focus + other = toga.TextInput() + widget.parent.add(other) + other_probe = get_probe(other) + + other.focus() + await probe.redraw() + assert not probe.has_focus + assert other_probe.has_focus + + widget.focus() + await probe.redraw() + assert probe.has_focus + assert not other_probe.has_focus + + +async def test_focus_noop(widget, probe): + "The widget cannot be given focus" + # Add a separate widget that can take take focus + other = toga.TextInput() + widget.parent.add(other) + other_probe = get_probe(other) + + other.focus() + await probe.redraw() + assert not probe.has_focus + assert other_probe.has_focus + + # Widget has *not* taken focus + widget.focus() + await probe.redraw() + assert not probe.has_focus + assert other_probe.has_focus + + async def test_text(widget, probe): "The text displayed on a widget can be changed" for text in TEXTS: @@ -195,7 +237,7 @@ async def test_flex_widget_size(widget, probe): # Check the initial widget size # Match isn't exact because of pixel scaling on some platforms assert probe.width == approx(100, rel=0.01) - assert probe.height == approx(100, rel=0.01) + assert probe.height == approx(200, rel=0.01) # Drop the fixed height, and make the widget flexible widget.style.flex = 1 diff --git a/testbed/tests/widgets/test_activityindicator.py b/testbed/tests/widgets/test_activityindicator.py index 71988ee57d..2e498a7f20 100644 --- a/testbed/tests/widgets/test_activityindicator.py +++ b/testbed/tests/widgets/test_activityindicator.py @@ -5,6 +5,7 @@ from ..conftest import skip_on_platforms from .properties import ( # noqa: F401 test_enable_noop, + test_focus_noop, ) diff --git a/testbed/tests/widgets/test_base.py b/testbed/tests/widgets/test_base.py new file mode 100644 index 0000000000..9a049439ff --- /dev/null +++ b/testbed/tests/widgets/test_base.py @@ -0,0 +1,153 @@ +import pytest + +import toga +from toga.colors import BLACK, BLUE, GREEN, RED +from toga.style import Pack +from toga.style.pack import HIDDEN, VISIBLE + +from .probe import get_probe + + +@pytest.fixture +async def widget(): + return toga.Box(style=Pack(width=100, height=200, background_color=RED)) + + +async def test_visibility(widget, probe): + "A widget (and it's children) can be made invisible" + child = toga.Box(style=Pack(width=75, height=100, background_color=GREEN)) + child_probe = get_probe(child) + + grandchild = toga.Button("Hello") + grandchild_probe = get_probe(grandchild) + child.add(grandchild) + + other = toga.Box(style=Pack(width=100, height=200, background_color=BLUE)) + other_probe = get_probe(other) + + widget.parent.add(other) + widget.add(child) + + await probe.redraw() + + # Widgets are all visible an in place + assert not probe.is_hidden + assert not child_probe.is_hidden + assert not grandchild_probe.is_hidden + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + + # Hide the widget + widget.style.visibility = HIDDEN + await probe.redraw() + + # Widgets are no longer visible. + assert probe.is_hidden + assert child_probe.is_hidden + assert grandchild_probe.is_hidden + # Making the widget invisible doesn't affect layout + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + + # Make widget visible again + widget.style.visibility = VISIBLE + await probe.redraw() + + # Widgets are all visible and in place again. + assert not probe.is_hidden + assert not child_probe.is_hidden + assert not grandchild_probe.is_hidden + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + + # Hide the widget again + widget.style.visibility = HIDDEN + await probe.redraw() + + # Widgets are no longer visible. + assert probe.is_hidden + assert child_probe.is_hidden + assert grandchild_probe.is_hidden + + # Mark the child style as visible. + child.style.visibility = VISIBLE + await probe.redraw() + + # Root widget isn't visible, so neither descendent is visible. + assert probe.is_hidden + assert child_probe.is_hidden + assert grandchild_probe.is_hidden + + # Explicitly mark the child style as hidden. + child.style.visibility = HIDDEN + await probe.redraw() + + # Root widget isn't visible, so neither descendent is visible. + assert probe.is_hidden + assert child_probe.is_hidden + assert grandchild_probe.is_hidden + + # Mark the root widget as visible again. + widget.style.visibility = VISIBLE + await probe.redraw() + + # Root widget is visible again but the child is explicitly hidden, + # so it and the grandchild are still hidden + assert not probe.is_hidden + assert child_probe.is_hidden + assert grandchild_probe.is_hidden + + +async def test_parenting(widget, probe): + "A widget can be reparented between containers" + box = widget.parent + + child = toga.Box(style=Pack(width=50, height=75, background_color=GREEN)) + child_probe = get_probe(child) + other = toga.Box(style=Pack(width=100, height=200, background_color=BLUE)) + other_probe = get_probe(other) + other_child = toga.Box(style=Pack(width=25, height=50, background_color=BLACK)) + other.add(other_child) + + # Layout has the test widget, plus other, horizontally laid out. + # Child isn't in the layout yet. + box.add(other) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_not_contained() + + # Add child to widget. + widget.add(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_layout(position=(0, 0), size=(50, 75)) + + # Re-add child to the *same* widget + widget.add(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_layout(position=(0, 0), size=(50, 75)) + + # Reparent child to other without removing first + other.add(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_layout(position=(125, 0), size=(50, 75)) + + # Remove child from the layout entirely + other.remove(child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(100, 0), size=(100, 200)) + child_probe.assert_not_contained() + + # Insert the child into the root layout + box.insert(1, child) + await probe.redraw() + probe.assert_layout(position=(0, 0), size=(100, 200)) + other_probe.assert_layout(position=(150, 0), size=(100, 200)) + child_probe.assert_layout(position=(100, 0), size=(50, 75)) diff --git a/testbed/tests/widgets/test_box.py b/testbed/tests/widgets/test_box.py index 575a6c31f6..0959a27db8 100644 --- a/testbed/tests/widgets/test_box.py +++ b/testbed/tests/widgets/test_box.py @@ -8,9 +8,10 @@ test_background_color_reset, test_enable_noop, test_flex_widget_size, + test_focus_noop, ) @pytest.fixture async def widget(): - return toga.Box(style=Pack(width=100, height=100)) + return toga.Box(style=Pack(width=100, height=200)) diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py index 908cdd8216..2cba072fc3 100644 --- a/testbed/tests/widgets/test_button.py +++ b/testbed/tests/widgets/test_button.py @@ -19,6 +19,12 @@ test_text_width_change, ) +# Buttons can't be given focus on mobile +if toga.platform.current_platform in {"android", "iOS"}: + from .properties import test_focus_noop # noqa: F401 +else: + from .properties import test_focus # noqa: F401 + @fixture async def widget(): diff --git a/testbed/tests/widgets/test_divider.py b/testbed/tests/widgets/test_divider.py index 7ff9f2795c..db2e137c4d 100644 --- a/testbed/tests/widgets/test_divider.py +++ b/testbed/tests/widgets/test_divider.py @@ -6,6 +6,7 @@ from ..conftest import skip_on_platforms from .properties import ( # noqa: F401 test_enable_noop, + test_focus_noop, ) diff --git a/testbed/tests/widgets/test_label.py b/testbed/tests/widgets/test_label.py index d9a1f6bc35..fdf3d2a8f0 100644 --- a/testbed/tests/widgets/test_label.py +++ b/testbed/tests/widgets/test_label.py @@ -11,6 +11,7 @@ test_color_reset, test_enabled, test_flex_horizontal_widget_size, + test_focus_noop, test_font, test_font_attrs, test_text, diff --git a/testbed/tests/widgets/test_progressbar.py b/testbed/tests/widgets/test_progressbar.py index c3e59507c6..543888c8b5 100644 --- a/testbed/tests/widgets/test_progressbar.py +++ b/testbed/tests/widgets/test_progressbar.py @@ -9,6 +9,12 @@ test_flex_horizontal_widget_size, ) +# Progressbar can't be given focus on mobile +if toga.platform.current_platform in {"android", "iOS"}: + from .properties import test_focus_noop # noqa: F401 +else: + from .properties import test_focus # noqa: F401 + @pytest.fixture async def widget(): diff --git a/testbed/tests/widgets/test_slider.py b/testbed/tests/widgets/test_slider.py index 75a8338447..e98f5d737f 100644 --- a/testbed/tests/widgets/test_slider.py +++ b/testbed/tests/widgets/test_slider.py @@ -12,6 +12,13 @@ test_flex_horizontal_widget_size, ) +# Slider can't be given focus on mobile +if toga.platform.current_platform in {"android", "iOS"}: + from .properties import test_focus_noop # noqa: F401 +else: + from .properties import test_focus # noqa: F401 + + # To ensure less than 1 pixel of error, the slider must be able to distinguish at least # 10,000 positions in continuous mode. # diff --git a/testbed/tests/widgets/test_switch.py b/testbed/tests/widgets/test_switch.py index 3c4e911e58..b965f531a9 100644 --- a/testbed/tests/widgets/test_switch.py +++ b/testbed/tests/widgets/test_switch.py @@ -15,6 +15,12 @@ test_text_width_change, ) +# Switches can't be given focus on mobile, or on GTK +if toga.platform.current_platform in {"android", "iOS", "linux"}: + from .properties import test_focus_noop # noqa: F401 +else: + from .properties import test_focus # noqa: F401 + @fixture async def widget(): diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 9976daa14f..08d01f09d2 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -1,3 +1,5 @@ +from abc import abstractmethod + from toga_winforms.colors import native_color from toga_winforms.libs import Point, Size, SystemColors @@ -14,8 +16,9 @@ def __init__(self, interface): self.create() self.interface.style.reapply() + @abstractmethod def create(self): - pass + ... def set_app(self, app): # No special handling required @@ -32,12 +35,10 @@ def container(self): @container.setter def container(self, container): if self.container: - if container: - raise RuntimeError("Already have a container") - else: - # container is set to None, removing self from the container.native - self._container.native.Controls.Remove(self.native) - self._container = None + assert container is None, "Widget already has a container" + # container is set to None, removing self from the container.native + self._container.native.Controls.Remove(self.native) + self._container = None elif container: # setting container, adding self to container.native self._container = container @@ -70,8 +71,7 @@ def set_enabled(self, value): self.native.Enabled = value def focus(self): - if self.native: - self.native.Focus() + self.native.Focus() # APPLICATOR @@ -80,24 +80,22 @@ def vertical_shift(self): return 0 def set_bounds(self, x, y, width, height): - if self.native: - # Root level widgets may require vertical adjustment to - # account for toolbars, etc. - if self.interface.parent is None: - vertical_shift = self.frame.vertical_shift - else: - vertical_shift = 0 + # Root level widgets may require vertical adjustment to + # account for toolbars, etc. + if self.interface.parent is None: + vertical_shift = self.frame.vertical_shift + else: + vertical_shift = 0 - self.native.Size = Size(width, height) - self.native.Location = Point(x, y + vertical_shift) + self.native.Size = Size(width, height) + self.native.Location = Point(x, y + vertical_shift) def set_alignment(self, alignment): # By default, alignment can't be changed pass def set_hidden(self, hidden): - if self.native: - self.native.Visible = not hidden + self.native.Visible = not hidden def set_font(self, font): # By default, font can't be changed @@ -125,9 +123,11 @@ def add_child(self, child): child.container = self.container def insert_child(self, index, child): - if self.container: + if self.viewport: + # we are the the top level container + child.container = self + else: child.container = self.container - self.container.native.Controls.SetChildIndex(child.native, index) def remove_child(self, child): child.container = None @@ -135,5 +135,6 @@ def remove_child(self, child): def refresh(self): self.rehint() + @abstractmethod def rehint(self): - pass + ... diff --git a/winforms/src/toga_winforms/widgets/datepicker.py b/winforms/src/toga_winforms/widgets/datepicker.py index d0f7bbaaa0..8aaf0c8630 100644 --- a/winforms/src/toga_winforms/widgets/datepicker.py +++ b/winforms/src/toga_winforms/widgets/datepicker.py @@ -41,7 +41,7 @@ def set_max_date(self, value): def rehint(self): # Height of a date input is known and fixed. # Width must be > 200 - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.PreferredSize.Height def set_on_change(self, handler): diff --git a/winforms/src/toga_winforms/widgets/detailedlist.py b/winforms/src/toga_winforms/widgets/detailedlist.py index 9b193cc74f..fb30db0d05 100644 --- a/winforms/src/toga_winforms/widgets/detailedlist.py +++ b/winforms/src/toga_winforms/widgets/detailedlist.py @@ -127,8 +127,8 @@ def scroll_to_row(self, row): self.native.EnsureVisible(row) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def build_item(self, row, index): item = WinForms.ListViewItem(row.title) diff --git a/winforms/src/toga_winforms/widgets/multilinetextinput.py b/winforms/src/toga_winforms/widgets/multilinetextinput.py index ea964ea817..c95629eed1 100644 --- a/winforms/src/toga_winforms/widgets/multilinetextinput.py +++ b/winforms/src/toga_winforms/widgets/multilinetextinput.py @@ -47,8 +47,8 @@ def get_value(self): return self.native.Text def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def set_on_change(self, handler): pass diff --git a/winforms/src/toga_winforms/widgets/numberinput.py b/winforms/src/toga_winforms/widgets/numberinput.py index a8a619c393..f57c0e740d 100644 --- a/winforms/src/toga_winforms/widgets/numberinput.py +++ b/winforms/src/toga_winforms/widgets/numberinput.py @@ -52,7 +52,7 @@ def set_font(self, font): self.native.Font = font._impl.native def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.PreferredSize.Height def set_on_change(self, handler): diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 3ef268d654..8e2984cc1e 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -158,8 +158,8 @@ def scroll_to_row(self, row): self.native.EnsureVisible(row) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) def remove_column(self, accessor): self.native.Columns.RemoveByKey(accessor) diff --git a/winforms/src/toga_winforms/widgets/textinput.py b/winforms/src/toga_winforms/widgets/textinput.py index a2c6c0da8d..ec46e42876 100644 --- a/winforms/src/toga_winforms/widgets/textinput.py +++ b/winforms/src/toga_winforms/widgets/textinput.py @@ -75,7 +75,7 @@ def rehint(self): # Height of a text input is known and fixed. # Width must be > 100 # print("REHINT TextInput", self, self.native.PreferredSize) - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.PreferredSize.Height def set_on_change(self, handler): diff --git a/winforms/src/toga_winforms/widgets/timepicker.py b/winforms/src/toga_winforms/widgets/timepicker.py index 462df0cbc9..71f43b1f58 100644 --- a/winforms/src/toga_winforms/widgets/timepicker.py +++ b/winforms/src/toga_winforms/widgets/timepicker.py @@ -45,7 +45,7 @@ def set_max_time(self, value): def rehint(self): # Height of a date input is known and fixed. # Width must be > 100 - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = self.native.PreferredSize.Height def set_on_change(self, handler): diff --git a/winforms/src/toga_winforms/widgets/webview.py b/winforms/src/toga_winforms/widgets/webview.py index 6d6c62a20b..fcdb12317f 100644 --- a/winforms/src/toga_winforms/widgets/webview.py +++ b/winforms/src/toga_winforms/widgets/webview.py @@ -164,5 +164,5 @@ def invoke_javascript(self, javascript): self.native.ExecuteScriptAsync(javascript) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index d9138991d8..aaa39b6b87 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -25,6 +25,10 @@ def assert_container(self, container): else: raise ValueError(f"cannot find {self.native} in {container_native}") + def assert_not_contained(self): + assert self.widget._impl.container is None + assert self.native.Parent is None + def assert_alignment(self, expected): # Winforms doesn't have a "Justified" alignment; it falls back to LEFT actual = self.alignment @@ -95,5 +99,25 @@ def assert_height(self, min_height, max_height): min_height <= self.height <= max_height ), f"Height ({self.height}) not in range ({min_height}, {max_height})" + def assert_layout(self, size, position): + # Widget is contained and in a window. + assert self.widget._impl.container is not None + assert self.native.Parent is not None + + # size and position is as expected. + assert (self.native.Width, self.native.Height) == size + assert ( + self.native.Left, + self.native.Top - self.widget._impl.container.vertical_shift, + ) == position + async def press(self): self.native.OnClick(EventArgs.Empty) + + @property + def is_hidden(self): + return not self.native.Visible + + @property + def has_focus(self): + return self.native.ContainsFocus diff --git a/winforms/tests_backend/widgets/textinput.py b/winforms/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..df30717e5b --- /dev/null +++ b/winforms/tests_backend/widgets/textinput.py @@ -0,0 +1,7 @@ +import System.Windows.Forms + +from .base import SimpleProbe + + +class TextInputProbe(SimpleProbe): + native_class = System.Windows.Forms.TextBox