diff --git a/examples/calculator.py b/examples/calculator.py index 0be9d3c..4cd4a53 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -5,7 +5,7 @@ from pyglet.window import key import cocos -from shimmer.components.box_layout import create_box_layout, BoxLayoutDefinition +from shimmer.components.box_layout import create_box_layout, BoxGridDefinition from shimmer.components.font import FontDefinition from shimmer.data_structures import White, Black from shimmer.keyboard import ( @@ -28,7 +28,7 @@ class Calculator(Window): ["1", "2", "3", "/"], ["C", "0", "=", "*"], ] - layout_definition = BoxLayoutDefinition(num_columns=4, num_rows=4) + layout_definition = BoxGridDefinition(num_columns=4, num_rows=4) def __init__(self): """Create a Calculator.""" diff --git a/poetry.lock b/poetry.lock index 72b4697..c6c3113 100644 --- a/poetry.lock +++ b/poetry.lock @@ -384,7 +384,7 @@ python-versions = "*" version = "0.1.9" [metadata] -content-hash = "bcc32f56ca9730dc09dc5ba12d1b8ab19aa3e33d294b6c36ef66a59689b6ac92" +content-hash = "5952c9d215077850544c7bfd1df07a961827b486c24b996e3decc7768797833e" python-versions = "^3.8" [metadata.files] diff --git a/shimmer/components/box_layout.py b/shimmer/components/box_layout.py index 35e1e49..cc8af27 100644 --- a/shimmer/components/box_layout.py +++ b/shimmer/components/box_layout.py @@ -2,7 +2,7 @@ from abc import abstractmethod from dataclasses import dataclass -from typing import List, Union, Optional, Type, Iterable +from typing import List, Union, Optional, Type, Iterable, Tuple import cocos from .box import Box, BoxDefinition @@ -15,16 +15,47 @@ @dataclass(frozen=True) -class BoxLayoutDefinition(BoxDefinition): +class BoxRowDefinition(BoxDefinition): """ - Definition of a layout of Boxes. + Definition of a row of Boxes. + + :param spacing: Pixel spacing to leave between boxes. + :param alignment: Whether boxes should be aligned with each other at the top, center + or bottom. Only has an impact if the boxes are of different sizes. + """ + + spacing: int = 10 + alignment: VerticalAlignment = VerticalAlignment.center + + +@dataclass(frozen=True) +class BoxColumnDefinition(BoxDefinition): + """ + Definition of a column of Boxes. + + :param spacing: Pixel spacing to leave between boxes. + :param alignment: Whether boxes should be aligned with each other on the left, center + or right. Only has an impact if the boxes are of different sizes. + """ + + spacing: int = 10 + alignment: HorizontalAlignment = HorizontalAlignment.center + + +@dataclass(frozen=True) +class BoxGridDefinition(BoxDefinition): + """ + Definition of a rectangular grid of Boxes. Supported layouts are: - - vertically column - - horizontal row - - rectangular grid + - single vertical column + - single horizontal row + - rectangular grid with multiple rows/columns :param spacing: Number of pixels to leave between Boxes. + Possible types: + - int: Pixel spacing in both x and y directions + - Tuple[int, int]: Pixel spacing in (x, y) directions respectively. :param num_columns: If given, boxes will fill row-by-row upwards; up to a maximum of `height`. :param num_rows: If given and `num_columns` is not, then boxes will fill column-by-column from left to right with no max width. @@ -37,20 +68,30 @@ class BoxLayoutDefinition(BoxDefinition): in each row or column. """ - spacing: int = 10 + spacing: Union[int, Tuple[int, int]] = 10 num_columns: Optional[int] = None num_rows: Optional[int] = 1 alignment: PositionalAnchor = CenterCenter + @property + def row_definition(self) -> BoxRowDefinition: + """Definition for each row of the grid.""" + spacing = self.spacing if isinstance(self.spacing, int) else self.spacing[0] + return BoxRowDefinition(spacing=spacing, alignment=self.alignment.vertical) -class BoxLayout(Box): - """A collection of Boxes with a well defined layout.""" + @property + def column_definition(self) -> BoxColumnDefinition: + """Definition for each column of the grid.""" + spacing = self.spacing if isinstance(self.spacing, int) else self.spacing[1] + return BoxColumnDefinition(spacing=spacing, alignment=self.alignment.horizontal) - definition_type: Type[BoxLayoutDefinition] = BoxLayoutDefinition + +class BoxLayoutBase(Box): + """A collection of Boxes with a well defined layout.""" def __init__( self, - definition: Optional[BoxLayoutDefinition] = None, + definition: Optional[BoxDefinition] = None, boxes: Optional[Iterable[Box]] = None, ): """ @@ -59,9 +100,8 @@ def __init__( :param definition: Definition of this Layout. :param boxes: List of Boxes to include in the layout. """ - super(BoxLayout, self).__init__(definition) + super(BoxLayoutBase, self).__init__(definition) self._boxes: List[Box] = [] - self.definition: BoxLayoutDefinition = self.definition for box in boxes or []: self.add(box) self.update_layout() @@ -75,7 +115,7 @@ def remove( :param obj: CocosNode to remove. :param no_resize: If True, then the size of this box is not dynamically changed. """ - super(BoxLayout, self).remove(obj, no_resize=no_resize) + super(BoxLayoutBase, self).remove(obj, no_resize=no_resize) if isinstance(obj, Box): self._boxes.remove(obj) self.update_layout() @@ -97,7 +137,7 @@ def add( :param no_resize: Ignored. :param position: Index to insert the box into the list of boxes. Defaults to the end. """ - super(BoxLayout, self).add(child, z, name) + super(BoxLayoutBase, self).add(child, z, name) if isinstance(child, Box): if position is None: self._boxes.append(child) @@ -110,9 +150,25 @@ def update_layout(self) -> None: """Update the position of all boxes in this Layout.""" -class BoxRow(BoxLayout): +class BoxRow(BoxLayoutBase): """Arranges boxes horizontally. Boxes are arranged from left to right.""" + definition_type: Type[BoxRowDefinition] = BoxRowDefinition + + def __init__( + self, + definition: Optional[BoxRowDefinition] = None, + boxes: Optional[Iterable[Box]] = None, + ): + """ + Create a new BoxRow. + + :param definition: Definition to use to layout the boxes. + :param boxes: The boxes to be laid out. More can be added later using `self.add(box)`. + """ + super(BoxRow, self).__init__(definition, boxes) + self.definition: BoxRowDefinition = self.definition + def update_layout(self) -> None: """Update the position of all boxes in this Layout.""" # Don't update layout if there are no boxes to layout. @@ -126,24 +182,40 @@ def update_layout(self) -> None: for box in self._boxes: box.x = x_total x_total += box.rect.width + self.definition.spacing - if self.definition.alignment.vertical == VerticalAlignment.bottom: + if self.definition.alignment == VerticalAlignment.bottom: box.y = 0 - elif self.definition.alignment.vertical == VerticalAlignment.center: + elif self.definition.alignment == VerticalAlignment.center: box.y = (tallest / 2) - (box.rect.height / 2) - elif self.definition.alignment.vertical == VerticalAlignment.top: + elif self.definition.alignment == VerticalAlignment.top: box.y = tallest - box.rect.height else: raise AssertionError( - f"{self.definition.alignment.vertical} " + f"{self.definition.alignment} " f"must be a member of {VerticalAlignment}." ) self.update_rect() -class BoxColumn(BoxLayout): +class BoxColumn(BoxLayoutBase): """Arranges boxes vertically. Boxes are arranged from bottom to top.""" + definition_type: Type[BoxColumnDefinition] = BoxColumnDefinition + + def __init__( + self, + definition: Optional[BoxColumnDefinition] = None, + boxes: Optional[Iterable[Box]] = None, + ): + """ + Create a new BoxColumn. + + :param definition: Definition to use to layout the boxes. + :param boxes: The boxes to be laid out. More can be added later using `self.add(box)`. + """ + super(BoxColumn, self).__init__(definition, boxes) + self.definition: BoxColumnDefinition = self.definition + def update_layout(self) -> None: """Update the position of all boxes in this Layout.""" # Don't update layout if there are no boxes to layout. @@ -158,15 +230,15 @@ def update_layout(self) -> None: box.y = y_total y_total += box.rect.height + self.definition.spacing - if self.definition.alignment.horizontal == HorizontalAlignment.left: + if self.definition.alignment == HorizontalAlignment.left: box.x = 0 - elif self.definition.alignment.horizontal == HorizontalAlignment.center: + elif self.definition.alignment == HorizontalAlignment.center: box.x = (widest / 2) - (box.rect.width / 2) - elif self.definition.alignment.horizontal == HorizontalAlignment.right: + elif self.definition.alignment == HorizontalAlignment.right: box.x = widest - box.rect.width else: raise AssertionError( - f"{self.definition.alignment.horizontal} " + f"{self.definition.alignment} " f"must be a member of {HorizontalAlignment}." ) @@ -174,7 +246,7 @@ def update_layout(self) -> None: def build_rectangular_grid( - definition: BoxLayoutDefinition, boxes: List[Box] + definition: BoxGridDefinition, boxes: List[Box] ) -> Union[BoxRow, BoxColumn]: """ Build a rectangular grid of boxes. @@ -187,27 +259,32 @@ def build_rectangular_grid( This is passed to all of the layouts created, including the nested ones. :param boxes: Boxes to include. """ + grid_type: Union[Type[BoxRow], Type[BoxColumn]] + element_type: Union[Type[BoxRow], Type[BoxColumn]] if definition.num_columns is not None: grid_type = BoxColumn element_type = BoxRow boxes_per_element = definition.num_columns - max_elements = definition.num_rows + max_elements_per_line = definition.num_rows elif definition.num_rows is not None: grid_type = BoxRow element_type = BoxColumn boxes_per_element = definition.num_rows - max_elements = definition.num_columns + max_elements_per_line = definition.num_columns else: raise ValueError( "Grid layout is undefined with both `num_columns` and `num_rows` undefined." ) - # Maximum elements is one per box - max_elements = max_elements if max_elements is not None else len(boxes) + # Maximum number of grid elements per row/column is one element + # per box to be placed in the grid. + max_elements_per_line = ( + max_elements_per_line if max_elements_per_line is not None else len(boxes) + ) element_index = 0 elements = [] while True: - if element_index >= max_elements: + if element_index >= max_elements_per_line: # Reached max requested num_columns/num_rows break @@ -220,26 +297,32 @@ def build_rectangular_grid( # Last element might have fewer than other elements, but that's ok. break - elements.append(element_type(definition, box_batch)) + if element_type is BoxRow: + elements.append(BoxRow(definition.row_definition, box_batch)) + else: + elements.append(BoxColumn(definition.column_definition, box_batch)) element_index += 1 - return grid_type(definition, elements) + if grid_type is BoxRow: + return BoxRow(definition.row_definition, elements) + else: + return BoxColumn(definition.column_definition, elements) def create_box_layout( - definition: BoxLayoutDefinition, boxes: List[Box] + definition: BoxGridDefinition, boxes: List[Box] ) -> Union[BoxRow, BoxColumn]: """ Create a layout of boxes based on the given definition. - :param definition: BoxLayoutDefinition to use. + :param definition: BoxGridDefinition to use. :param boxes: Boxes to include in the layout. :return: BowRow or BoxColumn containing the given boxes. """ width_height = definition.num_columns, definition.num_rows if width_height == (None, 1): - return BoxRow(definition, boxes) + return BoxRow(definition.row_definition, boxes) elif width_height == (1, None): - return BoxColumn(definition, boxes) + return BoxColumn(definition.column_definition, boxes) else: return build_rectangular_grid(definition, boxes) diff --git a/shimmer/programmable/code_block.py b/shimmer/programmable/code_block.py index 9f265c9..4114b32 100644 --- a/shimmer/programmable/code_block.py +++ b/shimmer/programmable/code_block.py @@ -3,8 +3,8 @@ from dataclasses import dataclass, field from typing import List, cast -from shimmer.alignment import LeftBottom, PositionalAnchor -from shimmer.components.box_layout import BoxColumn, BoxLayoutDefinition +from shimmer.alignment import HorizontalAlignment +from shimmer.components.box_layout import BoxColumn, BoxColumnDefinition from shimmer.programmable.instruction import ( InstructionDisplay, InstructionDisplayDefinition, @@ -13,7 +13,7 @@ @dataclass(frozen=True) -class CodeBlockDisplayDefinition(BoxLayoutDefinition): +class CodeBlockDisplayDefinition(BoxColumnDefinition): """Definition of how to display a CodeBlock.""" instruction_definition: InstructionDisplayDefinition = field( @@ -21,7 +21,7 @@ class CodeBlockDisplayDefinition(BoxLayoutDefinition): ) # Spacing between the vertically arranged instructions. spacing: int = 0 - alignment: PositionalAnchor = LeftBottom + alignment: HorizontalAlignment = HorizontalAlignment.left class CodeBlockDisplay(BoxColumn): diff --git a/shimmer/programmable/if_elif.py b/shimmer/programmable/if_elif.py index 1a414d4..78e8ca1 100644 --- a/shimmer/programmable/if_elif.py +++ b/shimmer/programmable/if_elif.py @@ -3,9 +3,9 @@ from dataclasses import dataclass, field from typing import cast, Optional, List -from shimmer.alignment import LeftBottom, PositionalAnchor +from shimmer.alignment import HorizontalAlignment from shimmer.components.box import Box -from shimmer.components.box_layout import BoxColumn, BoxLayoutDefinition +from shimmer.components.box_layout import BoxColumn, BoxColumnDefinition from shimmer.programmable.code_block import ( CodeBlockDisplay, CodeBlockDisplayDefinition, @@ -21,7 +21,7 @@ @dataclass(frozen=True) -class InstructionWithBlockDisplayDefinition(BoxLayoutDefinition): +class InstructionWithBlockDisplayDefinition(BoxColumnDefinition): """Definition of how to display an instruction with an associated CodeBlock.""" # Definition of the main If statement display @@ -35,7 +35,7 @@ class InstructionWithBlockDisplayDefinition(BoxLayoutDefinition): # How far the code block is indented by compared to the If instruction. code_block_indentation: int = 20 spacing: int = 0 - alignment: PositionalAnchor = LeftBottom + alignment: HorizontalAlignment = HorizontalAlignment.left class InstructionWithBlockDisplay(Box): diff --git a/shimmer/widgets/button.py b/shimmer/widgets/button.py index 6bc1df7..eba6075 100644 --- a/shimmer/widgets/button.py +++ b/shimmer/widgets/button.py @@ -248,7 +248,7 @@ def _on_press(self, x: int, y: int, buttons: int, modifiers: int) -> None: """ Called when the Box is clicked by the user. - Alternatively calls the `on_select` and `on_release` callback from the definition on each + Alternatively calls the `on_press` and `on_release` callback from the definition on each press. """ self._is_toggled = not self._is_toggled diff --git a/shimmer/widgets/multiple_choice_buttons.py b/shimmer/widgets/multiple_choice_buttons.py index f02ea6f..f816bb1 100644 --- a/shimmer/widgets/multiple_choice_buttons.py +++ b/shimmer/widgets/multiple_choice_buttons.py @@ -6,7 +6,7 @@ from shimmer.components.box import BoxDefinition, Box from shimmer.components.box_layout import ( - BoxLayoutDefinition, + BoxGridDefinition, BoxRow, BoxColumn, create_box_layout, @@ -29,7 +29,7 @@ class MultipleChoiceButtonsDefinition(BoxDefinition): button: ButtonDefinition = field(default_factory=ButtonDefinition) # How to arrange the multiple buttons. - layout: BoxLayoutDefinition = field(default_factory=BoxLayoutDefinition) + layout: BoxGridDefinition = field(default_factory=BoxGridDefinition) class MultipleChoiceButtons(Box): diff --git a/shimmer/widgets/window.py b/shimmer/widgets/window.py index 164ce4d..c05b20a 100644 --- a/shimmer/widgets/window.py +++ b/shimmer/widgets/window.py @@ -9,7 +9,7 @@ RightTop, ) from ..components.box import Box, BoxDefinition -from ..components.box_layout import BoxColumn, BoxLayoutDefinition +from ..components.box_layout import BoxColumn from ..components.draggable_box import DraggableBox, DraggableBoxDefinition from ..components.focus import make_focusable, VisualAndKeyboardFocusBox from ..components.font import FontDefinition, Calibri @@ -103,7 +103,7 @@ def __init__(self, definition: WindowDefinition): self.focus_box: Optional[VisualAndKeyboardFocusBox] = None # Add the inner box, which is the main body of the window excluding the title bar. - self.body = BoxColumn(BoxLayoutDefinition()) + self.body = BoxColumn() self.add(self.body) self.update_all() diff --git a/tests/test_display/interactive.py b/tests/test_display/interactive.py index cec056d..019d765 100644 --- a/tests/test_display/interactive.py +++ b/tests/test_display/interactive.py @@ -34,7 +34,8 @@ def __init__(self, test_name: str, test_description: Optional[str] = None): ) description = TextBox( TextBoxDefinition( - text=dedent(test_description or "").strip(), width=window_width, + text=dedent(test_description or "").strip().replace("\n", " "), + width=window_width, ) ) keyboard_handler_definition = KeyboardHandlerDefinition( diff --git a/tests/test_display/test_components/test_box_layout.py b/tests/test_display/test_components/test_box_layout.py index d99a7f8..ad47218 100644 --- a/tests/test_display/test_components/test_box_layout.py +++ b/tests/test_display/test_components/test_box_layout.py @@ -2,12 +2,14 @@ import pytest import cocos -from shimmer.alignment import VerticalAlignment, HorizontalAlignment, PositionalAnchor +from shimmer.alignment import VerticalAlignment, HorizontalAlignment from shimmer.components.box import BoxDefinition, Box from shimmer.components.box_layout import ( BoxRow, BoxColumn, - BoxLayoutDefinition, + BoxGridDefinition, + BoxRowDefinition, + BoxColumnDefinition, create_box_layout, ) from shimmer.data_structures import Color @@ -32,7 +34,7 @@ def test_box_row(mock_gui): """Test arranging boxes in a horizontal row.""" boxes = [Box(BoxDefinition(width=10 * i, height=100)) for i in range(1, 5)] - box_row = BoxRow(BoxLayoutDefinition(spacing=10), boxes) + box_row = BoxRow(BoxRowDefinition(spacing=10), boxes) assert box_row.rect.width == 130 assert box_row.rect.height == 100 assert box_row.bounding_rect_of_children() == cocos.rect.Rect(0, 0, 130, 100) @@ -51,8 +53,7 @@ def test_box_row_with_different_height_boxes(mock_gui, alignment): """Test arranging boxes of various heights in a horizontal row.""" boxes = [Box(BoxDefinition(width=40, height=10 * i)) for i in range(1, 5)] - anchor = PositionalAnchor(vertical=alignment, horizontal=HorizontalAlignment.center) - box_row = BoxRow(BoxLayoutDefinition(spacing=10, alignment=anchor), boxes) + box_row = BoxRow(BoxRowDefinition(spacing=10, alignment=alignment), boxes) assert box_row.rect.width == 190 assert box_row.rect.height == 40 assert box_row.bounding_rect_of_children() == cocos.rect.Rect(0, 0, 190, 40) @@ -80,7 +81,7 @@ def test_box_column(mock_gui): """Test arranging boxes in a vertical column.""" boxes = [Box(BoxDefinition(width=100, height=10 * i)) for i in range(1, 5)] - box_column = BoxColumn(BoxLayoutDefinition(spacing=10), boxes) + box_column = BoxColumn(BoxColumnDefinition(spacing=10), boxes) assert box_column.rect.width == 100 assert box_column.rect.height == 130 assert box_column.bounding_rect_of_children() == cocos.rect.Rect(0, 0, 100, 130) @@ -99,8 +100,7 @@ def test_box_column_with_different_width_boxes(mock_gui, alignment): """Test arranging boxes of various widths in a vertical column.""" boxes = [Box(BoxDefinition(width=10 * i, height=40)) for i in range(1, 5)] - anchor = PositionalAnchor(horizontal=alignment, vertical=VerticalAlignment.center) - box_column = BoxColumn(BoxLayoutDefinition(spacing=10, alignment=anchor), boxes) + box_column = BoxColumn(BoxColumnDefinition(spacing=10, alignment=alignment), boxes) assert box_column.rect.width == 40 assert box_column.rect.height == 190 assert box_column.bounding_rect_of_children() == cocos.rect.Rect(0, 0, 40, 190) @@ -128,7 +128,7 @@ def test_box_column_with_different_width_boxes(mock_gui, alignment): "definition,num_boxes,exp_outer_type,exp_outer,exp_inner", [ pytest.param( - BoxLayoutDefinition(num_columns=None, num_rows=1), + BoxGridDefinition(num_columns=None, num_rows=1), 10, BoxRow, 1, @@ -136,7 +136,7 @@ def test_box_column_with_different_width_boxes(mock_gui, alignment): id="One row", ), pytest.param( - BoxLayoutDefinition(num_columns=1, num_rows=None), + BoxGridDefinition(num_columns=1, num_rows=None), 10, BoxColumn, 1, @@ -144,7 +144,7 @@ def test_box_column_with_different_width_boxes(mock_gui, alignment): id="One column", ), pytest.param( - BoxLayoutDefinition(num_columns=2, num_rows=None), + BoxGridDefinition(num_columns=2, num_rows=None), 10, BoxColumn, 2, @@ -152,7 +152,7 @@ def test_box_column_with_different_width_boxes(mock_gui, alignment): id="2 columns", ), pytest.param( - BoxLayoutDefinition(num_columns=10, num_rows=None), + BoxGridDefinition(num_columns=10, num_rows=None), 10, BoxColumn, 10, @@ -160,7 +160,7 @@ def test_box_column_with_different_width_boxes(mock_gui, alignment): id="Max columns (10)", ), pytest.param( - BoxLayoutDefinition(num_columns=None, num_rows=10), + BoxGridDefinition(num_columns=None, num_rows=10), 10, BoxRow, 10, @@ -181,8 +181,10 @@ def test_create_box_layout( if exp_outer * exp_inner > num_boxes: # Enough boxes to need two rows or columns to contain them. - exp_inner_type = BoxRow if exp_outer_type is BoxColumn else BoxColumn - assert isinstance(first_child, exp_inner_type) + if exp_outer_type is BoxColumn: + assert isinstance(first_child, BoxRow) + else: + assert isinstance(first_child, BoxColumn) assert len(box_layout.get_children()) == exp_outer assert len(first_child.get_children()) == exp_inner else: