diff --git a/changes/3111.feature.rst b/changes/3111.feature.rst new file mode 100644 index 0000000000..0d925e1c16 --- /dev/null +++ b/changes/3111.feature.rst @@ -0,0 +1 @@ +The ``align_items`` and ``justify_content`` properties now have the aliases ``horizontal_align_items``, ``vertical_align_items``, ``horizontal_align_content`` and ``vertical_align_content`` that explicitly describe layout behavior in the named direction. diff --git a/core/src/toga/style/mixin.py b/core/src/toga/style/mixin.py index 92aff56235..6761d4a415 100644 --- a/core/src/toga/style/mixin.py +++ b/core/src/toga/style/mixin.py @@ -2,6 +2,9 @@ class StyleProperty: def __set_name__(self, mixin_cls, name): self.name = name + def __repr__(self): + return f"" + def __get__(self, widget, mixin_cls): return self if widget is None else getattr(widget.style, self.name) @@ -21,8 +24,13 @@ def style_mixin(style_cls): """ } - for name in dir(style_cls): - if not name.startswith("_") and isinstance(getattr(style_cls, name), property): - mixin_dict[name] = StyleProperty() + try: + _all_properties = style_cls._BASE_ALL_PROPERTIES + except AttributeError: + # Travertino 0.3 compatibility + _all_properties = style_cls._ALL_PROPERTIES + + for name in _all_properties[style_cls]: + mixin_dict[name] = StyleProperty() return type(style_cls.__name__ + "Mixin", (), mixin_dict) diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index 6bf9aa5843..156a0604bf 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -115,6 +115,11 @@ def _hidden(self) -> bool: ###################################################################### def update(self, **properties): + # Set direction first, as it may change the interpretation of direction-based + # property aliases in _update_property_name. + if direction := properties.pop("direction", None): + self.direction = direction + properties = { self._update_property_name(name.replace("-", "_")): value for name, value in properties.items() @@ -128,7 +133,7 @@ def reapply(self, *args, **kwargs): warnings.filterwarnings("ignore", category=DeprecationWarning) super().reapply(*args, **kwargs) - DEPRECATED_PROPERTIES = { + _DEPRECATED_PROPERTIES = { # Map each deprecated property name to its replacement. # alignment / align_items is handled separately. "padding": "margin", @@ -138,22 +143,38 @@ def reapply(self, *args, **kwargs): "padding_left": "margin_left", } - @classmethod - def _update_property_name(cls, name): - if new_name := cls.DEPRECATED_PROPERTIES.get(name): - cls._warn_deprecated(name, new_name, stacklevel=4) + _ALIASES = { + "horizontal_align_content": {ROW: "justify_content"}, + "horizontal_align_items": {COLUMN: "align_items"}, + "vertical_align_content": {COLUMN: "justify_content"}, + "vertical_align_items": {ROW: "align_items"}, + } + + def _update_property_name(self, name): + if aliases := self._ALIASES.get(name): + try: + name = aliases[self.direction] + except KeyError: + raise AttributeError( + f"{name!r} is not supported on a {self.direction}" + ) from None + + if new_name := self._DEPRECATED_PROPERTIES.get(name): + self._warn_deprecated(name, new_name, stacklevel=4) name = new_name return name - @staticmethod - def _warn_deprecated(old_name, new_name, stacklevel=3): + def _warn_deprecated(self, old_name, new_name, stacklevel=3): msg = f"Pack.{old_name} is deprecated; use {new_name} instead" warnings.warn(msg, DeprecationWarning, stacklevel=stacklevel) # Dot lookup def __getattribute__(self, name): + if name.startswith("_"): + return super().__getattribute__(name) + # Align_items and alignment are paired. Both can never be set at the same time; # if one is requested, and the other one is set, compute the requested value # from the one that is set. @@ -197,7 +218,7 @@ def __getattribute__(self, name): # Only CENTER remains return CENTER - return super().__getattribute__(type(self)._update_property_name(name)) + return super().__getattribute__(self._update_property_name(name)) def __setattr__(self, name, value): # Only one of these can be set at a time. @@ -979,3 +1000,11 @@ def __css__(self) -> str: # 'font_family', 'font_style', 'font_variant', 'font_weight', 'font_size' # FONT_CHOICES # ]) + +try: + _all_properties = Pack._BASE_ALL_PROPERTIES +except AttributeError: + # Travertino 0.3 compatibility + _all_properties = Pack._ALL_PROPERTIES + +_all_properties[Pack].update(Pack._ALIASES) diff --git a/core/tests/style/pack/__init__.py b/core/tests/style/pack/__init__.py index e69de29bb2..6e536dcaf0 100644 --- a/core/tests/style/pack/__init__.py +++ b/core/tests/style/pack/__init__.py @@ -0,0 +1,48 @@ +from toga.style.pack import Pack + + +def with_init(**kwargs): + return Pack(**kwargs) + + +def with_update(**kwargs): + style = Pack() + style.update(**kwargs) + return style + + +def with_setattr(**kwargs): + style = Pack() + for name, value in kwargs.items(): + setattr(style, name, value) + return style + + +def with_setitem(**kwargs): + style = Pack() + for name, value in kwargs.items(): + style[name] = value + return style + + +def with_setitem_hyphen(**kwargs): + style = Pack() + for name, value in kwargs.items(): + style[name.replace("_", "-")] = value + return style + + +def getitem(obj, name): + return obj[name] + + +def getitem_hyphen(obj, name): + return obj[name.replace("_", "-")] + + +def delitem(obj, name): + del obj[name] + + +def delitem_hyphen(obj, name): + del obj[name.replace("_", "-")] diff --git a/core/tests/style/pack/test_aliases.py b/core/tests/style/pack/test_aliases.py new file mode 100644 index 0000000000..25ccab0141 --- /dev/null +++ b/core/tests/style/pack/test_aliases.py @@ -0,0 +1,108 @@ +import pytest +from pytest import raises + +from toga.style.pack import CENTER, COLUMN, END, ROW + +from . import ( + delitem, + delitem_hyphen, + getitem, + getitem_hyphen, + with_init, + with_setattr, + with_setitem, + with_setitem_hyphen, + with_update, +) + + +@pytest.mark.parametrize( + "css_name, row_alias, column_alias, default", + [ + ( + "align_items", + "vertical_align_items", + "horizontal_align_items", + None, + ), + ( + "justify_content", + "horizontal_align_content", + "vertical_align_content", + "start", + ), + ], +) +@pytest.mark.parametrize( + "style_with", + (with_init, with_update, with_setattr, with_setitem, with_setitem_hyphen), +) +@pytest.mark.parametrize("get_fn", (getattr, getitem, getitem_hyphen)) +@pytest.mark.parametrize("del_fn", (delattr, delitem, delitem_hyphen)) +def test_align(css_name, row_alias, column_alias, default, style_with, get_fn, del_fn): + """The `vertical_align` and `horizontal_align` aliases work correctly.""" + # Row alias + style = style_with(**{row_alias: CENTER}) + assert get_fn(style, css_name) == CENTER + + del_fn(style, row_alias) + assert get_fn(style, css_name) == default + + style = style_with(**{css_name: CENTER}) + assert get_fn(style, row_alias) == CENTER + + del_fn(style, css_name) + assert get_fn(style, row_alias) == default + + # Column alias + style = style_with(**{"direction": COLUMN, column_alias: CENTER}) + assert get_fn(style, css_name) == CENTER + + del_fn(style, column_alias) + assert get_fn(style, css_name) == default + + style = style_with(**{"direction": COLUMN, css_name: CENTER}) + assert get_fn(style, column_alias) == CENTER + + del_fn(style, css_name) + assert get_fn(style, column_alias) == default + + # Column alias is not accepted in a row, and vice versa. + def assert_invalid_alias(alias, direction): + style = style_with(direction=direction) + raises_kwargs = dict( + expected_exception=AttributeError, + match=f"'{alias}' is not supported on a {direction}", + ) + + with raises(**raises_kwargs): + get_fn(style, alias) + with raises(**raises_kwargs): + setattr(style, alias, END) + with raises(**raises_kwargs): + del_fn(style, alias) + with raises(**raises_kwargs): + style.update(**{"direction": direction, alias: END}) + with raises(**raises_kwargs): + style.update(**{alias: END, "direction": direction}) + + assert_invalid_alias(column_alias, ROW) + assert_invalid_alias(row_alias, COLUMN) + + # Consistent values of direction and alias can be updated together, regardless of + # argument order. + style = style_with(direction=COLUMN) + style.update(**{"direction": ROW, row_alias: CENTER}) + assert get_fn(style, row_alias) == CENTER + assert get_fn(style, css_name) == CENTER + style.update(**{column_alias: END, "direction": COLUMN}) + assert get_fn(style, column_alias) == END + assert get_fn(style, css_name) == END + + style = style_with(direction=ROW) + style.update(**{"direction": COLUMN, column_alias: CENTER}) + assert get_fn(style, column_alias) == CENTER + assert get_fn(style, css_name) == CENTER + style.update(**{row_alias: END, "direction": ROW}) + assert get_fn(style, row_alias) == END + assert get_fn(style, css_name) == END diff --git a/core/tests/style/pack/test_deprecated_properties.py b/core/tests/style/pack/test_deprecated_properties.py index 6716a2b200..0a0b3f4427 100644 --- a/core/tests/style/pack/test_deprecated_properties.py +++ b/core/tests/style/pack/test_deprecated_properties.py @@ -15,49 +15,17 @@ Pack, ) - -def with_init(name, value): - return Pack(**{name: value}) - - -def with_update(name, value): - style = Pack() - style.update(**{name: value}) - return style - - -def with_setattr(name, value): - style = Pack() - setattr(style, name, value) - return style - - -def with_setitem(name, value): - style = Pack() - style[name] = value - return style - - -def with_setitem_hyphen(name, value): - style = Pack() - style[name.replace("_", "-")] = value - return style - - -def getitem(obj, name): - return obj[name] - - -def getitem_hyphen(obj, name): - return obj[name.replace("_", "-")] - - -def delitem(obj, name): - del obj[name] - - -def delitem_hyphen(obj, name): - del obj[name.replace("_", "-")] +from . import ( + delitem, + delitem_hyphen, + getitem, + getitem_hyphen, + with_init, + with_setattr, + with_setitem, + with_setitem_hyphen, + with_update, +) @pytest.mark.parametrize( @@ -80,7 +48,7 @@ def test_padding_margin(old_name, new_name, value, default, style_with, get_fn, """Padding (with deprecation warning) and margin map to each other.""" # Set the old name, then check the new name with pytest.warns(DeprecationWarning): - style = style_with(old_name, value) + style = style_with(**{old_name: value}) assert get_fn(style, new_name) == value # Delete the old name, check new name @@ -89,7 +57,7 @@ def test_padding_margin(old_name, new_name, value, default, style_with, get_fn, assert get_fn(style, new_name) == default # Set the new name, then check the old name - style = style_with(new_name, value) + style = style_with(**{new_name: value}) with pytest.warns(DeprecationWarning): assert get_fn(style, old_name) == value @@ -128,7 +96,7 @@ def test_alignment_align_items( """Alignment (with deprecation warning) and align_items map to each other.""" # Set alignment, check align_items with pytest.warns(DeprecationWarning): - style = style_with("alignment", alignment) + style = style_with(alignment=alignment) style.update(direction=direction, text_direction=text_direction) assert get_fn(style, "align_items") == align_items @@ -139,7 +107,7 @@ def test_alignment_align_items( assert get_fn(style, "align_items") is None # Set align_items, check alignment - style = style_with("align_items", align_items) + style = style_with(align_items=align_items) style.update(direction=direction, text_direction=text_direction) with pytest.warns(DeprecationWarning): diff --git a/core/tests/style/test_mixin.py b/core/tests/style/test_mixin.py index d5861efb39..46248c1b06 100644 --- a/core/tests/style/test_mixin.py +++ b/core/tests/style/test_mixin.py @@ -1,59 +1,74 @@ +import pytest from pytest import raises from toga.style import Pack from ..utils import ExampleWidget +params = ( + "name, value, default", + [ + ("flex", 1, 0), # Regular style + ("horizontal_align_content", "center", "start"), # Style alias + ], +) -def test_constructor(): + +@pytest.mark.parametrize(*params) +def test_constructor(name, value, default): """Style properties can be set with widget constructor kwargs.""" widget = ExampleWidget() assert widget.id.isdigit() - assert widget.style.flex == 0 + assert getattr(widget.style, name) == default assert widget.style.display == "pack" - widget = ExampleWidget(id="my-id", flex=1, display="none") + widget = ExampleWidget(**{"id": "my-id", name: value, "display": "none"}) assert widget.id == "my-id" - assert widget.style.flex == 1 + assert getattr(widget.style, name) == value assert widget.style.display == "none" with raises(NameError, match="Unknown style 'nonexistent'"): ExampleWidget(nonexistent=None) -def test_constructor_style(): +@pytest.mark.parametrize(*params) +def test_constructor_style(name, value, default): """If both a style object and kwargs are passed, the kwargs should take priority, and the style object should not be modified.""" - style = Pack(display="none", flex=1) - widget = ExampleWidget(style=style, flex=2) + style = Pack(**{"display": "none", name: default}) + widget = ExampleWidget(**{"style": style, name: value}) assert widget.style.display == "none" - assert widget.style.flex == 2 - assert style.flex == 1 + assert getattr(widget.style, name) == value + assert getattr(style, name) == default -def test_attribute(): +@pytest.mark.parametrize(*params) +def test_attribute(name, value, default): """Style properties can be accessed as widget properties.""" widget = ExampleWidget() - assert widget.flex == 0 - assert widget.style.flex == 0 + assert getattr(widget, name) == default + assert getattr(widget.style, name) == default + + setattr(widget, name, value) + assert getattr(widget, name) == value + assert getattr(widget.style, name) == value - widget.flex = 1 - assert widget.flex == 1 - assert widget.style.flex == 1 + delattr(widget, name) + assert getattr(widget, name) == default + assert getattr(widget.style, name) == default - del widget.flex - assert widget.flex == 0 - assert widget.style.flex == 0 + setattr(widget.style, name, value) + assert getattr(widget, name) == value + assert getattr(widget.style, name) == value - widget.style.flex = 2 - assert widget.flex == 2 - assert widget.style.flex == 2 + delattr(widget.style, name) + assert getattr(widget, name) == default + assert getattr(widget.style, name) == default - del widget.flex - assert widget.flex == 0 - assert widget.style.flex == 0 - # Check regular attributes still work correctly +def test_regular_attribute(): + """Regular attributes still work correctly.""" + widget = ExampleWidget() with raises(AttributeError): widget.my_attr widget.my_attr = 42 @@ -63,7 +78,8 @@ def test_attribute(): widget.my_attr -def test_class_attribute(): +@pytest.mark.parametrize(*params) +def test_class_attribute(name, value, default): """Getting a style attribute from the class should return a property object.""" - prop = ExampleWidget.flex - assert type(prop).__name__ == "StyleProperty" + prop = getattr(ExampleWidget, name) + assert repr(prop) == f"" diff --git a/docs/reference/style/pack.rst b/docs/reference/style/pack.rst index a79d714f80..4d6666b3b5 100644 --- a/docs/reference/style/pack.rst +++ b/docs/reference/style/pack.rst @@ -71,6 +71,8 @@ indicates children will be packed horizontally; left-to-right if **Initial value:** ``start`` +**Aliases:** ``vertical_align_items`` in a row, ``horizontal_align_items`` in a column + The alignment of this box's children along the cross axis. A row's cross axis is vertical, so ``start`` aligns children to the top, while ``end`` aligns them to the bottom. For columns, ``start`` is on the left if ``text_direction`` is ``ltr``, and the @@ -83,6 +85,9 @@ right if ``rtl``. **Initial value:** ``start`` +**Aliases:** ``horizontal_align_content`` in a row, ``vertical_align_content`` in a +column + The alignment of this box's children along the main axis. A column's main axis is vertical, so ``start`` aligns children to the top, while ``end`` aligns them to the bottom. For rows, ``start`` is on the left if ``text_direction`` is ``ltr``, and the