Skip to content

Commit

Permalink
Add justify_content style (beeware#3054)
Browse files Browse the repository at this point in the history
Adds a `justify_content` style option to Pack to manage main axis content alignment.
  • Loading branch information
mhsmith authored Dec 31, 2024
1 parent 2bb6317 commit 08e7d38
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 50 deletions.
1 change: 1 addition & 0 deletions changes/1194.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added the ``justify_content`` style, which aligns children along a box's main axis.
34 changes: 28 additions & 6 deletions core/src/toga/style/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
DIRECTION_CHOICES = Choices(ROW, COLUMN)
ALIGN_ITEMS_CHOICES = Choices(START, CENTER, END)
ALIGNMENT_CHOICES = Choices(LEFT, RIGHT, TOP, BOTTOM, CENTER) # Deprecated
JUSTIFY_CONTENT_CHOICES = Choices(START, CENTER, END)
GAP_CHOICES = Choices(integer=True)

SIZE_CHOICES = Choices(NONE, integer=True)
Expand Down Expand Up @@ -648,12 +649,15 @@ def _layout_row_children(
# self._debug(f" {min_width=} {width=}")

# self._debug(f"PASS 2 COMPLETE; USED {width=}")
if use_all_width:
width = max(width, available_width)
if use_all_width or self.width != NONE:
extra = max(0, available_width - width)
width += extra
else:
extra = 0
# self._debug(f"COMPUTED {min_width=} {width=}")

# Pass 3: Set the horizontal position of each child, and establish row height
offset = 0
offset = self._initial_offset(extra)
height = 0
min_height = 0
for child in node.children:
Expand Down Expand Up @@ -940,12 +944,15 @@ def _layout_column_children(
# self._debug(f" {min_height=} {height=}")

# self._debug(f"PASS 2 COMPLETE; USED {height=}")
if use_all_height:
height = max(height, available_height)
if use_all_height or self.height != NONE:
extra = max(0, available_height - height)
height += extra
else:
extra = 0
# self._debug(f"COMPUTED {min_height=} {height=}")

# Pass 3: Set the vertical position of each element, and establish column width
offset = 0
offset = self._initial_offset(extra)
width = 0
min_width = 0
for child in node.children:
Expand Down Expand Up @@ -995,6 +1002,14 @@ def _layout_column_children(

return min_width, width, min_height, height

def _initial_offset(self, extra):
if self.justify_content == END:
return extra
elif self.justify_content == CENTER:
return extra / 2
else: # START
return 0

def __css__(self) -> str:
css = []
# display
Expand Down Expand Up @@ -1029,6 +1044,10 @@ def __css__(self) -> str:
if self.align_items:
css.append(f"align-items: {self.align_items};")

# justify_content
if self.justify_content != START:
css.append(f"justify-content: {self.justify_content};")

# gap
if self.gap:
css.append(f"gap: {self.gap}px;")
Expand Down Expand Up @@ -1082,6 +1101,9 @@ def __css__(self) -> str:
Pack.validated_property("direction", choices=DIRECTION_CHOICES, initial=ROW)
Pack.validated_property("align_items", choices=ALIGN_ITEMS_CHOICES)
Pack.validated_property("alignment", choices=ALIGNMENT_CHOICES) # Deprecated
Pack.validated_property(
"justify_content", choices=JUSTIFY_CONTENT_CHOICES, initial=START
)
Pack.validated_property("gap", choices=GAP_CHOICES, initial=0)

Pack.validated_property("width", choices=SIZE_CHOICES, initial=NONE)
Expand Down
112 changes: 112 additions & 0 deletions core/tests/style/pack/layout/test_justify_content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import pytest
from travertino.size import at_least

from toga.style.pack import Pack

from ..utils import ExampleNode, ExampleViewport, assert_layout


class Box(ExampleNode):
def __init__(self, *args, **kwargs):
kwargs.setdefault("size", (at_least(0), at_least(0)))
super().__init__(*args, **kwargs)


@pytest.fixture
def viewport():
return ExampleViewport(640, 480)


@pytest.fixture
def root():
return Box(
"parent",
style=Pack(gap=20),
children=[
Box("child_0", style=Pack(width=100, height=100, margin=10)),
Box("child_1", style=Pack(width=100, height=100)),
],
)


@pytest.mark.parametrize(
"direction, text_direction, justify_content, origin_0, origin_1",
[
("row", "ltr", "start", (10, 10), (140, 0)),
("row", "ltr", "center", (210, 10), (340, 0)),
("row", "ltr", "end", (410, 10), (540, 0)),
#
("row", "rtl", "start", (530, 10), (400, 0)),
("row", "rtl", "center", (330, 10), (200, 0)),
("row", "rtl", "end", (130, 10), (0, 0)),
#
("column", None, "start", (10, 10), (0, 140)),
("column", None, "center", (10, 130), (0, 260)),
("column", None, "end", (10, 250), (0, 380)),
],
)
def test_justify_content(
viewport, root, direction, text_direction, justify_content, origin_0, origin_1
):
root.style.update(direction=direction, justify_content=justify_content)
if text_direction:
root.style.text_direction = text_direction

root.style.layout(root, viewport)
assert_layout(
root,
(240, 120) if direction == "row" else (120, 240),
(640, 480),
{
"origin": (0, 0),
"content": (640, 480),
"children": [
{"origin": origin_0, "content": (100, 100)},
{"origin": origin_1, "content": (100, 100)},
],
},
)


@pytest.mark.parametrize(
"direction, text_direction, origin_0, origin_1, content_1",
[
("row", "ltr", (10, 10), (140, 0), (500, 100)),
("row", "rtl", (530, 10), (0, 0), (500, 100)),
("column", None, (10, 10), (0, 140), (100, 340)),
],
)
@pytest.mark.parametrize("justify_content", ["start", "center", "end"])
def test_justify_content_flex(
viewport,
root,
direction,
text_direction,
justify_content,
origin_0,
origin_1,
content_1,
):
"""justify_content has no effect when a child is flexible."""
root.style.update(direction=direction, justify_content=justify_content)
if text_direction:
root.style.text_direction = text_direction

child_style = root.children[1].style
delattr(child_style, "width" if direction == "row" else "height")
child_style.flex = 1

root.style.layout(root, viewport)
assert_layout(
root,
(140, 120) if direction == "row" else (120, 140),
(640, 480),
{
"origin": (0, 0),
"content": (640, 480),
"children": [
{"origin": origin_0, "content": (100, 100)},
{"origin": origin_1, "content": content_1},
],
},
)
16 changes: 16 additions & 0 deletions core/tests/style/pack/test_css.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,22 @@
"flex-direction: column; flex: 0.0 0 auto; align-items: center;",
id="column-align_items-center",
),
# justify_content
pytest.param(
Pack(justify_content=START),
"flex-direction: row; flex: 0.0 0 auto;",
id="gap-start",
),
pytest.param(
Pack(justify_content=CENTER),
"flex-direction: row; flex: 0.0 0 auto; justify-content: center;",
id="gap-center",
),
pytest.param(
Pack(justify_content=END),
"flex-direction: row; flex: 0.0 0 auto; justify-content: end;",
id="gap-end",
),
# Gap
pytest.param(
Pack(gap=42),
Expand Down
24 changes: 20 additions & 4 deletions docs/reference/style/pack.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,26 @@ indicates children will be packed horizontally; left-to-right if

**Initial value:** ``start``

The alignment of children relative to the outside of the packed box, along the cross
axis. A row's main axis is horizontal, so its cross axis is vertical; ``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 right if ``rtl``.
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
right if ``rtl``.

``justify_content``
-------------------

**Values:** ``start`` | ``center`` | ``end``

**Initial value:** ``start``

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
right if ``rtl``.

This property only has an effect if there is some free space in the main axis. For
example, if any children have a non-zero ``flex`` value, then they will consume all
the available space, and ``justify_content`` will make no difference to the layout.

``gap``
-------
Expand Down
57 changes: 17 additions & 40 deletions examples/layout/layout/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,52 +5,25 @@

class ExampleLayoutApp(toga.App):
def startup(self):
self.button_hide = toga.Button(
text="Hide label",
style=Pack(margin=10, width=120),
on_press=self.hide_label,
)

self.button_add = toga.Button(
text="Add image",
style=Pack(margin=10, width=120),
on_press=self.add_image,
)

self.button_hide = toga.Button(text="Hide label", on_press=self.hide_label)
self.button_add = toga.Button(text="Add image", on_press=self.add_image)
self.button_remove = toga.Button(
text="Remove image",
style=Pack(margin=10, width=120),
on_press=self.remove_image,
enabled=False,
text="Remove image", on_press=self.remove_image, enabled=False
)

self.button_insert = toga.Button(
text="Insert image",
style=Pack(margin=10, width=120),
on_press=self.insert_image,
text="Insert image", on_press=self.insert_image
)

self.button_reparent = toga.Button(
text="Reparent image",
style=Pack(margin=10, width=120),
on_press=self.reparent_image,
enabled=False,
text="Reparent image", on_press=self.reparent_image, enabled=False
)

self.button_add_to_scroll = toga.Button(
text="Add new label",
style=Pack(margin=10, width=120),
on_press=self.add_label,
text="Add new label", on_press=self.add_label
)

self.content_box = toga.Box(
children=[], style=Pack(direction=COLUMN, margin=10, flex=1)
)
self.content_box = toga.Box(children=[], style=Pack(direction=COLUMN, gap=4))

image = toga.Image("resources/tiberius.png")
self.image_view = toga.ImageView(
image, style=Pack(margin=10, width=60, height=60)
)
self.image_view = toga.ImageView(image, style=Pack(width=60, height=60))

# this tests adding children during init, before we have an implementation
self.button_box = toga.Box(
Expand All @@ -62,12 +35,18 @@ def startup(self):
self.button_remove,
self.button_add_to_scroll,
],
style=Pack(direction=COLUMN),
style=Pack(direction=COLUMN, width=120, gap=20),
)

self.box = toga.Box(
children=[],
style=Pack(direction=ROW, margin=10, align_items=CENTER, flex=1),
style=Pack(
direction=ROW,
margin=20,
gap=20,
align_items=CENTER,
justify_content=CENTER,
),
)

# this tests adding children when we already have an impl but no window or app
Expand Down Expand Up @@ -123,9 +102,7 @@ def reparent_image(self, sender):

def add_label(self, sender=None):
# this tests adding children when we already have an impl, window and app
new_label = toga.Label(
f"Label {len(self.content_box.children)}", style=Pack(margin=2, width=70)
)
new_label = toga.Label(f"Label {len(self.content_box.children)}")
self.content_box.add(new_label)
self.labels.append(new_label)

Expand Down

0 comments on commit 08e7d38

Please sign in to comment.