diff --git a/arcade/examples/gui/4_gui_and_camera.py b/arcade/examples/gui/4_with_camera.py similarity index 98% rename from arcade/examples/gui/4_gui_and_camera.py rename to arcade/examples/gui/4_with_camera.py index 892e1c92d..072465e2d 100644 --- a/arcade/examples/gui/4_gui_and_camera.py +++ b/arcade/examples/gui/4_with_camera.py @@ -7,7 +7,7 @@ At the beginning of the game, the UI camera is used, to apply some animations. If arcade and Python are properly installed, you can run this example with: -python -m arcade.examples.gui.4_gui_and_camera +python -m arcade.examples.gui.4_with_camera """ from __future__ import annotations @@ -37,6 +37,7 @@ class MyCoinGame(UIView): def __init__(self): super().__init__() + self.bg_color = arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE # basic camera setup self.keys = set() @@ -262,6 +263,5 @@ def on_key_release(self, symbol: int, modifiers: int) -> Optional[bool]: if __name__ == "__main__": window = arcade.Window(1280, 720, "GUI Example: Coin Game (Camera)", resizable=False) - window.background_color = arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE window.show_view(MyCoinGame()) window.run() diff --git a/arcade/examples/gui/5_uicolor_picker.py b/arcade/examples/gui/5_uicolor_picker.py index 95a3bd8a6..d0b6a7309 100644 --- a/arcade/examples/gui/5_uicolor_picker.py +++ b/arcade/examples/gui/5_uicolor_picker.py @@ -144,12 +144,7 @@ def __init__(self): ColorButton( color_name=name, color=color, - # giving width and height is a workaround for a bug in the grid layout - # we would want to set size_hint=(1, 1) and let - # the grid layout handle the size - width=self.window.width // 5, - height=self.window.height // 4, - size_hint=None, + size_hint=(1, 1), ) ) self.grid.add(button, row=i // 5, column=i % 5) diff --git a/arcade/examples/gui/size_hints.py b/arcade/examples/gui/6_size_hints.py similarity index 97% rename from arcade/examples/gui/size_hints.py rename to arcade/examples/gui/6_size_hints.py index 32e577e78..11cfe494b 100644 --- a/arcade/examples/gui/size_hints.py +++ b/arcade/examples/gui/6_size_hints.py @@ -14,7 +14,7 @@ always be set. If arcade and Python are properly installed, you can run this example with: -python -m arcade.examples.gui.size_hints +python -m arcade.examples.gui.6_size_hints """ from __future__ import annotations diff --git a/arcade/examples/gui/exp_hidden_password.py b/arcade/examples/gui/exp_hidden_password.py index 144560469..1d0b9bba8 100644 --- a/arcade/examples/gui/exp_hidden_password.py +++ b/arcade/examples/gui/exp_hidden_password.py @@ -2,7 +2,11 @@ This example demonstrates how to create a custom text input which hides the contents behind a custom character, as often -required for login screens +required for login screens. + +Due to a bug in the current version of pyglet, the example uses ENTER to switch +fields instead of TAB. This will be fixed in future versions. +(https://github.com/pyglet/pyglet/issues/1197) If arcade and Python are properly installed, you can run this example with: python -m arcade.examples.gui.exp_hidden_password @@ -11,55 +15,100 @@ from __future__ import annotations import arcade -from arcade.gui import UIManager, UIInputText, UIOnClickEvent +from arcade.gui import UIInputText, UIOnClickEvent, UIView from arcade.gui.experimental.password_input import UIPasswordInput from arcade.gui.widgets.buttons import UIFlatButton from arcade.gui.widgets.layout import UIGridLayout, UIAnchorLayout from arcade.gui.widgets.text import UILabel +from arcade import resources + +# Load kenny fonts shipped with arcade +resources.load_system_fonts() -class MyView(arcade.View): +class MyView(UIView): def __init__(self): super().__init__() - self.ui = UIManager() + self.background_color = arcade.uicolor.BLUE_BELIZE_HOLE grid = UIGridLayout( size_hint=(0, 0), # wrap children - row_count=3, # user, pw and login button + row_count=5, # title | user, pw | login button column_count=2, # label and input field vertical_spacing=10, horizontal_spacing=5, ) + grid.with_padding(all=50) + grid.with_background(color=arcade.uicolor.GREEN_GREEN_SEA) + + title = grid.add( + UILabel(text="Login", width=150, font_size=20, font_name="Kenney Future"), + column=0, + row=0, + column_span=2, + ) + title.with_padding(bottom=20) - grid.add(UILabel(text="Username:", width=80), column=0, row=0) - self.username_input = grid.add(UIInputText(), column=1, row=0) - - grid.add(UILabel(text="Password:", width=80), column=0, row=1) - self.password_input = grid.add(UIPasswordInput(), column=1, row=1) + grid.add(UILabel(text="Username:", width=80, font_name="Kenney Future"), column=0, row=1) + self.username_input = grid.add( + UIInputText(width=150, font_name="Kenney Future"), column=1, row=1 + ) - self.login_button = grid.add(UIFlatButton(text="Login"), column=0, row=2, column_span=2) + grid.add(UILabel(text="Password:", width=80, font_name="Kenney Future"), column=0, row=2) + self.password_input = grid.add( + UIPasswordInput(width=150, font_name="Kenney Future"), column=1, row=2 + ) + self.password_input.with_background(color=arcade.uicolor.GREEN_GREEN_SEA) + # set background to prevent full render on blinking caret + + self.login_button = grid.add( + UIFlatButton(text="Login", height=30, width=150, size_hint=(1, None)), + column=0, + row=3, + column_span=2, + ) self.login_button.on_click = self.on_login + # add warning label + self.warning_label = grid.add( + UILabel( + text="Use [enter] to switch fields, then enter to login", + width=150, + font_size=10, + font_name="Kenney Future", + ), + column=0, + row=4, + column_span=2, + ) + anchor = UIAnchorLayout() # to center grid on screen anchor.add(grid) - self.ui.add(anchor) - - def on_login(self, event: UIOnClickEvent): - print(f"User logged in with: {self.username_input.text} {self.password_input.text}") - - def on_show_view(self): - self.window.background_color = arcade.color.DARK_BLUE_GRAY - # Enable UIManager when view is shown to catch window events - self.ui.enable() - - def on_hide_view(self): - # Disable UIManager when view gets inactive - self.ui.disable() - - def on_draw(self): - self.clear() - self.ui.draw() + self.add_widget(anchor) + + # activate username input field + self.username_input.activate() + + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + # if username field active, switch fields with enter + if self.username_input.active: + if symbol == arcade.key.ENTER: + self.username_input.deactivate() + self.password_input.activate() + return True + # if password field active, login with enter + elif self.password_input.active: + if symbol == arcade.key.ENTER: + self.password_input.deactivate() + self.on_login(None) + return True + return False + + def on_login(self, event: UIOnClickEvent | None): + username = self.username_input.text.strip() + password = self.password_input.text.strip() + print(f"User logged in with: {username} {password}") if __name__ == "__main__": diff --git a/arcade/examples/gui/grid_layout.py b/arcade/examples/gui/grid_layout.py index 82874b8ba..ddb6c3364 100644 --- a/arcade/examples/gui/grid_layout.py +++ b/arcade/examples/gui/grid_layout.py @@ -21,12 +21,12 @@ def __init__(self): super().__init__() self.ui = UIManager() - dummy1 = UIDummy(width=100, height=100) + dummy1 = UIDummy(size_hint=(1, 1)) dummy2 = UIDummy(width=50, height=50) dummy3 = UIDummy(width=50, height=50, size_hint=(0.5, 0.5)) - dummy4 = UIDummy(width=100, height=100) - dummy5 = UIDummy(width=200, height=100) - dummy6 = UIDummy(width=100, height=300) + dummy4 = UIDummy(size_hint=(1, 1)) + dummy5 = UIDummy(size_hint=(1, 1)) + dummy6 = UIDummy(size_hint=(1, 1)) subject = ( UIGridLayout( @@ -34,8 +34,8 @@ def __init__(self): row_count=3, size_hint=(0.5, 0.5), ) - .with_border() - .with_padding() + .with_border(color=arcade.color.RED) + .with_padding(all=2) ) subject.add(child=dummy1, column=0, row=0) @@ -50,6 +50,10 @@ def __init__(self): self.ui.add(anchor) + self.ui.execute_layout() + print(subject.size) + self.grid = subject + def on_show_view(self): self.window.background_color = arcade.color.DARK_BLUE_GRAY # Enable UIManager when view is shown to catch window events @@ -59,6 +63,11 @@ def on_hide_view(self): # Disable UIManager when view gets inactive self.ui.disable() + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + if symbol == arcade.key.D: + self.grid.legacy_mode = not self.grid.legacy_mode + return True + def on_draw(self): self.clear() self.ui.draw() diff --git a/arcade/examples/gui/gui_slider.py b/arcade/examples/gui/gui_slider.py deleted file mode 100644 index 61185b99f..000000000 --- a/arcade/examples/gui/gui_slider.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -GUI Slider Example - -This example demonstrates how to create a GUI slider and react to -changes in its value. - -There are two other ways of handling update events. For more -information on this subject, see the flat_button example. - -If Python and Arcade are installed, this example can be run from the command line with: -python -m arcade.examples.gui.gui_slider -""" - -import arcade -from arcade.gui import UIManager, UILabel -from arcade.gui.events import UIOnChangeEvent -from arcade.gui.widgets.slider import UISlider - - -class MyView(arcade.View): - def __init__(self): - super().__init__() - # Required, create a UI manager to handle all UI widgets - self.ui = UIManager() - - # Create our pair of widgets - ui_slider = UISlider(value=50, width=600, height=50) - label = UILabel(text=f"{ui_slider.value:02.0f}", font_size=20) - - # Change the label's text whenever the slider is dragged - # See the gui_flat_button example for more information. - @ui_slider.event() - def on_change(event: UIOnChangeEvent): - label.text = f"{ui_slider.value:02.0f}" - label.fit_content() - - # Create a layout to hold the label and the slider - ui_anchor_layout = arcade.gui.widgets.layout.UIAnchorLayout() - ui_anchor_layout.add(child=ui_slider, anchor_x="center_x", anchor_y="center_y") - ui_anchor_layout.add(child=label, align_y=50) - - self.ui.add(ui_anchor_layout) - - def on_show_view(self): - self.window.background_color = arcade.color.DARK_BLUE_GRAY - # Enable UIManager when view is shown to catch window events - self.ui.enable() - - def on_hide_view(self): - # Disable UIManager when view gets inactive - self.ui.disable() - - def on_draw(self): - self.clear() - self.ui.draw() - - -if __name__ == "__main__": - window = arcade.Window(1280, 720, "UIExample", resizable=True) - window.show_view(MyView()) - window.run() diff --git a/arcade/gui/experimental/password_input.py b/arcade/gui/experimental/password_input.py index d417488a1..c0cbd3ece 100644 --- a/arcade/gui/experimental/password_input.py +++ b/arcade/gui/experimental/password_input.py @@ -6,7 +6,12 @@ class UIPasswordInput(UIInputText): - """A password input field. The text is hidden with asterisks.""" + """A password input field. The text is hidden with asterisks. + + Hint: It is recommended to set a background color to prevent full render cycles + when the caret blinks. + + """ def on_event(self, event: UIEvent) -> Optional[bool]: """Remove new lines from the input, which are not allowed in passwords.""" diff --git a/arcade/gui/surface.py b/arcade/gui/surface.py index 8ecb3a215..4f2f17b5c 100644 --- a/arcade/gui/surface.py +++ b/arcade/gui/surface.py @@ -197,11 +197,12 @@ def limit(self, rect: Rect): w = max(w, 1) h = max(h, 1) + # round to nearest pixel, to avoid off by 1-pixel errors in ui viewport_rect = LBWH( - int(l * self._pixel_ratio), - int(b * self._pixel_ratio), - int(w * self._pixel_ratio), - int(h * self._pixel_ratio), + round(l * self._pixel_ratio), + round(b * self._pixel_ratio), + round(w * self._pixel_ratio), + round(h * self._pixel_ratio), ) self.fbo.viewport = viewport_rect.viewport diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 4a23520c8..c0f6f3ed5 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -60,7 +60,7 @@ class UIWidget(EventDispatcher, ABC): rect: Rect = Property(LBWH(0, 0, 1, 1)) # type: ignore visible: bool = Property(True) # type: ignore - size_hint: Optional[Tuple[float, float]] = Property(None) # type: ignore + size_hint: Optional[Tuple[float | None, float | None]] = Property(None) # type: ignore size_hint_min: Optional[Tuple[float, float]] = Property(None) # type: ignore size_hint_max: Optional[Tuple[float, float]] = Property(None) # type: ignore @@ -83,9 +83,9 @@ def __init__( height: float = 100, children: Iterable["UIWidget"] = tuple(), # Properties which might be used by layouts - size_hint=None, # in percentage - size_hint_min=None, # in pixel - size_hint_max=None, # in pixel + size_hint: Optional[Tuple[float | None, float | None]] = None, # in percentage + size_hint_min: Optional[Tuple[float, float]] = None, # in pixel + size_hint_max: Optional[Tuple[float, float]] = None, # in pixel **kwargs, ): self._requires_render = True @@ -510,6 +510,9 @@ def center_on_screen(self: W) -> W: self.rect = self.rect.align_center(center) return self + def __repr__(self): + return f"<{self.__class__.__name__} {self.rect.lbwh}>" + class UIInteractiveWidget(UIWidget): """Base class for widgets which use mouse interaction (hover, pressed, clicked) @@ -785,8 +788,9 @@ def _do_layout(self): super()._do_layout() def do_layout(self): - """Triggered by the UIManager before rendering, :class:`UILayout` s should place - themselves and/or children. Do layout will be triggered on children afterward. + """do_layout is triggered by the UIManager before rendering. + :class:`UILayout` should position their children. + Afterward, do_layout of child widgets will be triggered. Use :meth:`UIWidget.trigger_render` to trigger a rendering before the next frame, this will happen automatically if the position or size of this widget changed. diff --git a/arcade/gui/widgets/layout.py b/arcade/gui/widgets/layout.py index 61745667d..b8c715ee2 100644 --- a/arcade/gui/widgets/layout.py +++ b/arcade/gui/widgets/layout.py @@ -1,12 +1,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Iterable, List, Optional, TypeVar, cast +from typing import Dict, Iterable, List, Optional, Tuple, TypeVar from typing_extensions import Literal, override from arcade.gui.property import bind, unbind -from arcade.gui.widgets import UILayout, UIWidget +from arcade.gui.widgets import UILayout, UIWidget, _ChildEntry __all__ = ["UILayout", "UIAnchorLayout", "UIBoxLayout", "UIGridLayout"] @@ -381,6 +381,8 @@ def do_layout(self): for (child, data), main_size, ortho_size in zip( self._children, main_sizes, orthogonal_sizes ): + # apply calculated sizes, condition regarding existing size_hint + # are already covered in calculation input new_rect = child.rect.resize( height=main_size if self.vertical else ortho_size, width=ortho_size if self.vertical else main_size, @@ -418,21 +420,30 @@ def do_layout(self): class UIGridLayout(UILayout): - """Place widgets in a grid layout. This is similar to tkinter's ``grid`` - layout geometry manager. + """Place widgets in a grid. - Defaults to ``size_hint = (0, 0)``. + Widgets can span multiple columns and rows. + By default, the layout will only use the minimal required space (``size_hint = (0, 0)``). - Supports the options ``size_hint``, ``size_hint_min``, and - ``size_hint_max``. + Widgets can provide a ``size_hint`` to request dynamic space relative to the layout size. + A size_hint of ``(1, 1)`` will fill the available space, while ``(0.1, 0.1)`` + will use maximum 10% of the layouts total size. Children are resized based on ``size_hint``. Maximum and minimum - ``size_hint``s only take effect if a ``size_hint`` is given. ``size_hint_min`` is automatically - updated based on the minimal required space by children. + ``size_hint``s only take effect if a ``size_hint`` is given. + + The layouts ``size_hint_min`` is automatically + updated based on the minimal required space by children, after layouting. + + The width of columns and height of rows are calculated based on the size hints of the children. + The highest size_hint_min of a child in a column or row is used. If a child has no size_hint, + the actual size is considered. Args: x: ``x`` coordinate of bottom left corner. y: ``y`` coordinate of bottom left corner. + width: Width of the layout. + height: Height of the layout. align_horizontal: Align children in orthogonal direction. Options include ``left``, ``center``, and ``right``. align_vertical: Align children in orthogonal direction. Options @@ -454,6 +465,8 @@ def __init__( *, x=0, y=0, + width=1, + height=1, align_horizontal="center", align_vertical="center", children: Iterable[UIWidget] = tuple(), @@ -468,8 +481,8 @@ def __init__( super(UIGridLayout, self).__init__( x=x, y=y, - width=1, - height=1, + width=width, + height=height, children=children, size_hint=size_hint, size_hint_max=size_hint_max, @@ -485,15 +498,16 @@ def __init__( self.align_horizontal = align_horizontal self.align_vertical = align_vertical - bind(self, "_children", self._update_size_hints) - bind(self, "_border_width", self._update_size_hints) + bind(self, "_children", self._trigger_size_hint_update) + bind(self, "_border_width", self._trigger_size_hint_update) - bind(self, "_padding_left", self._update_size_hints) - bind(self, "_padding_right", self._update_size_hints) - bind(self, "_padding_top", self._update_size_hints) - bind(self, "_padding_bottom", self._update_size_hints) + bind(self, "_padding_left", self._trigger_size_hint_update) + bind(self, "_padding_right", self._trigger_size_hint_update) + bind(self, "_padding_top", self._trigger_size_hint_update) + bind(self, "_padding_bottom", self._trigger_size_hint_update) # initially update size hints + # TODO is this required? self._update_size_hints() def add( @@ -556,200 +570,253 @@ def prepare_layout(self): def _update_size_hints(self): self._size_hint_requires_update = False - max_width_per_column: list[list[tuple[int, int]]] = [ - [(0, 1) for _ in range(self.row_count)] for _ in range(self.column_count) - ] - max_height_per_row: list[list[tuple[int, int]]] = [ - [(0, 1) for _ in range(self.column_count)] for _ in range(self.row_count) - ] - - for child, data in self._children: - col_num = data["column"] - row_num = data["row"] - col_span = data["column_span"] - row_span = data["row_span"] - - shmn_w, shmn_h = UILayout.min_size_of(child) - - for i in range(col_num, col_span + col_num): - max_width_per_column[i][row_num] = (0, 0) - - max_width_per_column[col_num][row_num] = (shmn_w, col_span) - - for i in range(row_num, row_span + row_num): - max_height_per_row[i][col_num] = (0, 0) - - max_height_per_row[row_num][col_num] = (shmn_h, row_span) - - principal_width_ratio_list = [] - principal_height_ratio_list = [] - - for row in max_height_per_row: - principal_height_ratio_list.append(max(height / (span or 1) for height, span in row)) + if not self.children: + self.size_hint_min = (0, 0) + return - for col in max_width_per_column: - principal_width_ratio_list.append(max(width / (span or 1) for width, span in col)) + # 0. generate list for all rows and columns + columns = [] + for i in range(self.column_count): + columns.append([]) + rows = [] + for i in range(self.row_count): + rows.append([]) + + for entry in self._children: + col_num = entry.data["column"] + row_num = entry.data["row"] + col_span = entry.data["column_span"] + row_span = entry.data["row_span"] + + # we put the entry in all columns and rows it spans + for c in range(col_span): + columns[col_num + c].append(entry) + + for r in range(row_span): + rows[row_num + r].append(entry) + + # 1.a per column, collect max of size_hint_min and max size_hint + minimal_width_per_column = [] + for col in columns: + min_width = 0 + max_sh = 0 + for entry in col: + col_span = entry.data["column_span"] + # if the cell spans multiple columns, + # we need to reduce the minimal required width by the horizontal spacing + consumed_space = self._horizontal_spacing if col_span > 1 else 0 + + min_w, _ = UILayout.min_size_of(entry.child) + min_width = max(min_width, min_w / col_span - consumed_space) + + shw, _ = entry.child.size_hint or (0, 0) + max_sh = max(max_sh, shw) if shw else max_sh + + minimal_width_per_column.append(min_width) + + # 1.b per row, collect max of size_hint_min and max size_hint + minimal_height_per_row = [] + for row in rows: + min_height = 0 + max_sh = 0 + for entry in row: + row_span = entry.data["row_span"] + # if the cell spans multiple rows, + # we need to reduce the minimal required height by the vertical spacing + consumed_space = self._vertical_spacing if row_span > 1 else 0 + + _, min_h = UILayout.min_size_of(entry.child) + min_height = max(min_height, min_h / row_span - consumed_space) + + _, shh = entry.child.size_hint or (0, 0) + max_sh = max(max_sh, shh) if shh else max_sh + + minimal_height_per_row.append(min_height) base_width = self._padding_left + self._padding_right + 2 * self._border_width base_height = self._padding_top + self._padding_bottom + 2 * self._border_width - content_height = sum(principal_height_ratio_list) + self.row_count * self._vertical_spacing - content_width = ( - sum(principal_width_ratio_list) + self.column_count * self._horizontal_spacing + self.size_hint_min = ( + base_width + + sum(minimal_width_per_column) + + (self.column_count - 1) * self._horizontal_spacing, + base_height + + sum(minimal_height_per_row) + + (self.row_count - 1) * self._vertical_spacing, ) - self.size_hint_min = (base_width + content_width, base_height + content_height) - def do_layout(self): """Executes the layout algorithm. - Children are placed in a grid layout based on the size hints.""" - initial_left_x = self.content_rect.left - start_y = self.content_rect.top + Children are placed in a grid layout based on the size hints. + + Algorithm + --------- + 0. generate list for all rows and columns + 1. per column, collect max of size_hint_min and max size_hint (widths) + 2. per row, collect max of size_hint_min and max size_hint (heights) + 3. use box layout algorithm to distribute space + 4. place widgets in grid layout + + """ + + # skip if no children if not self.children: return - child_sorted_row_wise = cast( - list[list[UIWidget]], - [[None for _ in range(self.column_count)] for _ in range(self.row_count)], + # 0. generate list for all rows and columns + columns = [] + for i in range(self.column_count): + columns.append([]) + rows = [] + for i in range(self.row_count): + rows.append([]) + + lookup: Dict[Tuple[int, int], _ChildEntry] = {} + for entry in self._children: + col_num = entry.data["column"] + row_num = entry.data["row"] + col_span = entry.data["column_span"] + row_span = entry.data["row_span"] + + # we put the entry in all columns and rows it spans + for c in range(col_span): + columns[col_num + c].append(entry) + + for r in range(row_span): + rows[row_num + r].append(entry) + + lookup[(col_num, row_num)] = entry + + # 1.a per column, collect max of size_hint_min and max size_hint + minimal_width_per_column = [] + max_size_hint_per_column = [] + for col in columns: + min_width = 0 + max_sh = 0 + for entry in col: + col_span = entry.data["column_span"] + # if the cell spans multiple columns, + # we need to reduce the minimal required width by the horizontal spacing + consumed_space = self._horizontal_spacing if col_span > 1 else 0 + + min_w, _ = UILayout.min_size_of(entry.child) + min_width = max(min_width, min_w / col_span - consumed_space) + + shw, _ = entry.child.size_hint or (0, 0) + max_sh = max(max_sh, shw) if shw else max_sh + + minimal_width_per_column.append(min_width) + max_size_hint_per_column.append(max_sh) + + # 1.b per row, collect max of size_hint_min and max size_hint + minimal_height_per_row = [] + max_size_hint_per_row = [] + for row in rows: + min_height = 0 + max_sh = 0 + for entry in row: + row_span = entry.data["row_span"] + # if the cell spans multiple rows, + # we need to reduce the minimal required height by the vertical spacing + consumed_space = self._vertical_spacing if row_span > 1 else 0 + + _, min_h = UILayout.min_size_of(entry.child) + min_height = max(min_height, min_h / row_span - consumed_space) + + _, shh = entry.child.size_hint or (0, 0) + max_sh = max(max_sh, shh) if shh else max_sh + + minimal_height_per_row.append(min_height) + max_size_hint_per_row.append(max_sh) + + # 2. use box layout algorithm to distribute space + column_constraints = [ + _C(minimal_width_per_column[i], None, max_size_hint_per_column[i]) + for i in range(self.column_count) + ] + column_sizes = _box_axis_algorithm( + column_constraints, + self.content_width - (self.column_count - 1) * self._horizontal_spacing, ) - max_width_per_column: list[list[tuple[float, int]]] = [ - [(0, 1) for _ in range(self.row_count)] for _ in range(self.column_count) + row_constraints = [ + _C(minimal_height_per_row[i], None, max_size_hint_per_row[i]) + for i in range(self.row_count) ] - max_height_per_row: list[list[tuple[float, int]]] = [ - [(0, 1) for _ in range(self.column_count)] for _ in range(self.row_count) - ] - - for child, data in self._children: - col_num = data["column"] - row_num = data["row"] - col_span = data["column_span"] - row_span = data["row_span"] - - for i in range(col_num, col_span + col_num): - max_width_per_column[i][row_num] = (0, 0) - - max_width_per_column[col_num][row_num] = (child.width, col_span) - - for i in range(row_num, row_span + row_num): - max_height_per_row[i][col_num] = (0, 0) - - max_height_per_row[row_num][col_num] = (child.height, row_span) - - for row in child_sorted_row_wise[row_num : row_num + row_span]: - row[col_num : col_num + col_span] = [child] * col_span - - principal_height_ratio_list = [] - principal_width_ratio_list = [] - - # Making cell height same for each row. - for row in max_height_per_row: - principal_height_ratio = max( - (height + self._vertical_spacing) / (span or 1) for height, span in row - ) - principal_height_ratio_list.append(principal_height_ratio) - for i, (height, span) in enumerate(row): - if (height + self._vertical_spacing) / (span or 1) < principal_height_ratio: - row[i] = (principal_height_ratio * span, span) - - # Making cell width same for each column. - for col in max_width_per_column: - principal_width_ratio = max( - (width + self._horizontal_spacing) / (span or 1) for width, span in col - ) - principal_width_ratio_list.append(principal_width_ratio) - for i, (width, span) in enumerate(col): - if (width + self._horizontal_spacing) / (span or 1) < principal_width_ratio: - col[i] = (principal_width_ratio * span, span) - - content_height = sum(principal_height_ratio_list) + self.row_count * self._vertical_spacing - content_width = ( - sum(principal_width_ratio_list) + self.column_count * self._horizontal_spacing + row_sizes = _box_axis_algorithm( + row_constraints, self.content_height - (self.row_count - 1) * self._vertical_spacing ) - def ratio(dimensions: list) -> list: - """Used to calculate ratio of the elements based on the minimum value in the parameter. - - Args: - dimension: List containing max height or width of the - cells. - """ - ratio_value = sum(dimensions) or 1 - return [dimension / ratio_value for dimension in dimensions] - - expandable_height_ratio = ratio(principal_width_ratio_list) - expandable_width_ratio = ratio(principal_height_ratio_list) - - total_available_height = self.content_rect.top - content_height - self.content_rect.bottom - total_available_width = self.content_rect.right - content_width - self.content_rect.left - - # Row wise rendering children - for row_num, row in enumerate(child_sorted_row_wise): - max_height_row = 0 - start_x = initial_left_x - - for col_num, child in enumerate(row): - constant_height = max_height_per_row[row_num][col_num][0] - height_expand_ratio = expandable_height_ratio[col_num] - available_height = constant_height + total_available_height * height_expand_ratio - - constant_width = max_width_per_column[col_num][row_num][0] - width_expand_ratio = expandable_width_ratio[row_num] - available_width = constant_width + total_available_width * width_expand_ratio - - if child is not None and constant_width != 0 and constant_height != 0: - new_rect = child.rect - sh_w, sh_h = 0, 0 - - if child.size_hint: - sh_w, sh_h = (child.size_hint[0] or 0), (child.size_hint[1] or 0) - shmn_w, shmn_h = child.size_hint_min or (None, None) - shmx_w, shmx_h = child.size_hint_max or (None, None) - - new_height = max(shmn_h or 0, sh_h * available_height or child.height) - if shmx_h: - new_height = min(shmx_h, new_height) - - new_width = max(shmn_w or 0, sh_w * available_width or child.width) - if shmx_w: - new_width = min(shmx_w, new_width) - - new_rect = new_rect.resize(width=new_width, height=new_height) - - cell_height = constant_height + self._vertical_spacing - cell_width = constant_width + self._horizontal_spacing - - center_y = start_y - (cell_height / 2) - center_x = start_x + (cell_width / 2) - - start_x += cell_width - - if self.align_vertical == "top": - new_rect = new_rect.align_top(start_y) - elif self.align_vertical == "bottom": - new_rect = new_rect.align_bottom(start_y - cell_height) - else: - new_rect = new_rect.align_y(center_y) - - if self.align_horizontal == "left": - new_rect = new_rect.align_left(start_x - cell_width) - elif self.align_horizontal == "right": - new_rect = new_rect.align_right(start_x) - else: - new_rect = new_rect.align_x(center_x) + # 3. place widgets in grid layout + start_y = self.content_rect.top + for row_num in range(self.row_count): + start_x = self.content_rect.left + for col_num in range(self.column_count): + entry = lookup.get((col_num, row_num)) + if not entry: + # still need to update start_x + start_x += column_sizes[col_num] + self._horizontal_spacing + continue + + # TODO handle row_span and col_span + child = entry.child + new_rect = child.rect + + # combine size of cells this entry spans and add spacing + column_span = entry.data["column_span"] + cell_width: float = sum(column_sizes[col_num : col_num + column_span]) + cell_width += (column_span - 1) * self._horizontal_spacing + + row_span = entry.data["row_span"] + cell_height: float = sum(row_sizes[row_num : row_num + row_span]) + cell_height += (row_span - 1) * self._vertical_spacing + + # apply calculated sizes, when size_hint is given + shw, shh = child.size_hint or (None, None) + shmn_w, shmn_h = child.size_hint_min or (None, None) + shmx_w, shmx_h = child.size_hint_max or (None, None) + + new_width = child.width + if shw is not None: + new_width = min(cell_width, shw * self.content_width) + new_width = max(new_width, shmn_w or 0) + if shmx_w is not None: + new_width = min(new_width, shmx_w) + + new_height = child.height + if shh is not None: + new_height = min(cell_height, shh * self.content_height) + new_height = max(new_height, shmn_h or 0) + if shmx_h is not None: + new_height = min(new_height, shmx_h) + + new_rect = new_rect.resize(width=new_width, height=new_height) + + # align within cell + center_y = start_y - (cell_height / 2) + center_x = start_x + (cell_width / 2) + + if self.align_vertical == "top": + new_rect = new_rect.align_top(start_y) + elif self.align_vertical == "bottom": + new_rect = new_rect.align_bottom(start_y - row_sizes[row_num]) + else: + new_rect = new_rect.align_y(center_y) - child.rect = new_rect + if self.align_horizontal == "left": + new_rect = new_rect.align_left(start_x) + elif self.align_horizontal == "right": + new_rect = new_rect.align_right(start_x + cell_width) + else: + new_rect = new_rect.align_x(center_x) - # done due to row-wise rendering as start_y doesn't resets - # like start_x, specific to row span. - row_span = max_height_per_row[row_num][col_num][1] or 1 - actual_row_height = cell_height / row_span - if actual_row_height > max_height_row: - max_height_row = actual_row_height + # update child rect + child.rect = new_rect - start_y -= max_height_row + start_x += column_sizes[col_num] + self._horizontal_spacing + start_y -= row_sizes[row_num] + self._vertical_spacing @dataclass diff --git a/doc/example_code/gui_4_with_camera.rst b/doc/example_code/gui_4_with_camera.rst new file mode 100644 index 000000000..286dcd11d --- /dev/null +++ b/doc/example_code/gui_4_with_camera.rst @@ -0,0 +1,15 @@ +:orphan: + +.. _gui_4_with_camera: + +GUI with Camera +=============== + +.. image:: images/gui_4_with_camera.png + :width: 600px + :align: center + :alt: Screen shot of advanced button usage + +.. literalinclude:: ../../arcade/examples/gui/4_with_camera.py + :caption: 4_with_camera.py + :linenos: diff --git a/doc/example_code/gui_exp_hidden_password.rst b/doc/example_code/gui_exp_hidden_password.rst new file mode 100644 index 000000000..1851ff290 --- /dev/null +++ b/doc/example_code/gui_exp_hidden_password.rst @@ -0,0 +1,17 @@ +:orphan: + +.. _gui_exp_hidden_password: + +GUI Hidden Password +=================== + +The following example demonstrates how to make use of the experimental widget. + +.. image:: images/gui_exp_hidden_password.png + :width: 600px + :align: center + :alt: Screen shot + +.. literalinclude:: ../../arcade/examples/gui/exp_hidden_password.py + :caption: exp_hidden_password.py + :linenos: diff --git a/doc/example_code/images/gui_4_with_camera.png b/doc/example_code/images/gui_4_with_camera.png new file mode 100644 index 000000000..565cca456 Binary files /dev/null and b/doc/example_code/images/gui_4_with_camera.png differ diff --git a/doc/example_code/images/gui_exp_hidden_password.png b/doc/example_code/images/gui_exp_hidden_password.png new file mode 100644 index 000000000..f35ea463f Binary files /dev/null and b/doc/example_code/images/gui_exp_hidden_password.png differ diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index f5e6691d5..6677459c7 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -610,6 +610,11 @@ Graphical User Interface :ref:`gui_3_buttons` +.. figure:: images/thumbs/gui_4_with_camera.png + :figwidth: 170px + :target: gui_4_with_camera.html + + :ref:`gui_4_with_camera` .. figure:: images/thumbs/gui_5_uicolor_picker.png :figwidth: 170px @@ -621,6 +626,23 @@ Graphical User Interface Not all existing examples made it into this section. You can find more under `Arcade GUI Examples `_ +Experimental Widgets +^^^^^^^^^^^^^^^^^^^^ + +.. figure:: images/thumbs/gui_exp_hidden_password.png + :figwidth: 170px + :target: gui_exp_hidden_password.html + + :ref:`gui_exp_hidden_password` + + +.. note:: + + Experimental widgets are not yet part of the official release. + They are subject to change and may not be fully functional. + + Feedback is very welcome, please let us know what you think about them. + Grid-Based Games diff --git a/doc/programming_guide/gui/concept.rst b/doc/programming_guide/gui/concepts.rst similarity index 100% rename from doc/programming_guide/gui/concept.rst rename to doc/programming_guide/gui/concepts.rst diff --git a/doc/programming_guide/gui/index.rst b/doc/programming_guide/gui/index.rst index 231bbdff6..1cd815bdd 100644 --- a/doc/programming_guide/gui/index.rst +++ b/doc/programming_guide/gui/index.rst @@ -6,15 +6,22 @@ GUI Arcade's GUI module provides you classes to interact with the user using buttons, labels and much more. -You can find examples for the GUI module in the :ref:`gui_examples_overview`. +Behind the scenes the GUI uses a different rendering system than the rest of the engine. +The GUI is especially designed to allow resizing and scaling of the widgets, +which can cause problems with the normal rendering system. + +Usage examples are listed under :ref:`gui_examples_overview`. + +We recommend to read the :ref:`gui_concepts`, to get a better understanding of the +GUI module and its components. -Using those classes is way easier if the general concepts are known. -It is recommended to read through them. .. toctree:: - :maxdepth: 1 + :maxdepth: 2 + + concepts + layouts + style - concept - style diff --git a/doc/programming_guide/gui/layouts.rst b/doc/programming_guide/gui/layouts.rst new file mode 100644 index 000000000..ccf465bae --- /dev/null +++ b/doc/programming_guide/gui/layouts.rst @@ -0,0 +1,126 @@ +.. _gui_layouts: + +GUI Layouts +----------- + +Included Layouts +================ + +The GUI module provides a way to layout your GUI elements in a structured way. +Layouts dynamically resize the elements based on the size of the window. + +The layouts are an optional part of the GUI, but highly recommended to use. +Mixing self positioning and layout positioning is possible, but can lead to unexpected results. + +Layouts apply their layouting right before the rendering phase, +so the layout is always up-to-date for the rendering, +but will not be consistent after instantiation in your ``__init__()`` method. + +To circumvent this, you can trigger a layout run by calling the `UIManager.execute_layout()`. + + +The following layouts are available: + +- :class:`arcade.gui.UIBoxLayout` + + The `UIBoxLayout` class is used to arrange widgets in a + horizontal or vertical box layout. Here are some key points to understand: + + 1. **Orientation**: + The layout can be either horizontal or vertical, controlled by the `vertical` parameter. + + 2. **Alignment**: + Widgets can be aligned within the layout using the `align` parameter. + + 3. **Spacing**: + The layout can have spacing between the widgets, controlled by the `space_between` parameter. + + 4. **Size Hints**: + The layout resizes widgets based on their `size_hint`, `size_hint_min` and `size_hint_max`. + + 5. **Size**: + The layout automatically updates its `size_hint_min` based on the minimal + required space by its children after layout phase. + + In summary, `UIBoxLayout` provides a simple way to arrange widgets in a horizontal or + vertical layout, allowing for alignment and spacing between the widgets. + +- :class:`arcade.gui.UIAnchorLayout` + + The `UIAnchorLayout` class is used to arrange widgets + in the center or at the edges of the layout. + All children are independently anchored the specified anchor points. + + Here are some key points to understand: + + 1. **Anchor**: + The widget can be anchored to the center or at the edges of the layout using + the `anchor_x` and `anchor_y` parameters. In addition to the anchor point, + the widget can be offset from the anchor point using the `offset_x` and `offset_y` parameters. + + 2. **Padding**: + The layout can have padding to ensure spacing to the borders, + controlled by the `padding` parameter. + + 3. **Size**: + The `UIAnchorLayout` is the only layout which by default fills the whole available space. + (Default `size_hint=(1, 1)`) + + 3. **Size Hints**: + The layout resizes widgets based on their `size_hint`, `size_hint_min` and `size_hint_max`. + + In summary, `UIAnchorLayout` provides a way to anchor widgets to a position within the layout. + This allows for flexible positioning of widgets within the layout. + + +- :class:`arcade.gui.UIGridLayout` + + The `UIGridLayout` class is used to arrange widgets in a grid format. Here are some key points to understand: + + 1. **Grid Structure**: + The layout is divided into a specified number of columns and rows. Widgets can be placed in these grid cells. + + 2. **Spanning**: + Widgets can span multiple columns and/or rows using the `column_span` and `row_span` parameters. + + 3. **Dynamic Sizing**: + Widgets can provide a `size_hint` to request dynamic space relative to the layout size. + This means that the widget can grow or shrink based on the available space in the layout. + + 4. **Alignment**: + Widgets can be aligned within their grid cells using the `align_horizontal` and `align_vertical` parameters. + + 5. **Spacing**: + The layout can have horizontal and vertical spacing between the grid cells, + controlled by the `horizontal_spacing` and `vertical_spacing` parameters. + + 6. **Size**: + The layout automatically updates its `size_hint_min` based on the minimal + required space by its children after layouting. + + 7. **Size Hints**: + The layout resizes widgets based on their `size_hint`, `size_hint_min` and `size_hint_max`. + + In summary, `UIGridLayout` provides a flexible way to arrange widgets in a grid, + allowing for dynamic resizing and alignment based on the layout's size + and the widgets' size hints. + +When to use which layout? +========================= + +Choosing the right layout depends on the desired layout structure. +But often there are multiple ways to achieve the same layout. + +Here are some guidelines to help you choose the right layout: + +- Use `UIAnchorLayout` for anchoring widgets to a position within the layout. + This is mostly useful to position widgets freely within the bounds of the layout. + Commonly used as the root layout for the whole GUI. + +- Use `UIBoxLayout` for simple horizontal or vertical layouts. + This is useful for arranging widgets in a row or column. + Multiple `UIBoxLayout` can be nested to create more complex layouts. + +- Use `UIGridLayout` for arranging widgets in a grid format. + This is useful for creating a grid of widgets, where columns and rows should each have a fixed size. + diff --git a/tests/unit/gui/conftest.py b/tests/unit/gui/conftest.py index c6cc98131..a34f64cd9 100644 --- a/tests/unit/gui/conftest.py +++ b/tests/unit/gui/conftest.py @@ -11,5 +11,5 @@ def __init__(self, *args, **kwargs): @fixture -def uimanager(window) -> InteractionUIManager: +def ui(window) -> InteractionUIManager: return InteractionUIManager() diff --git a/tests/unit/gui/test_interactions.py b/tests/unit/gui/test_interactions.py index 96cd0570c..a6cce7825 100644 --- a/tests/unit/gui/test_interactions.py +++ b/tests/unit/gui/test_interactions.py @@ -7,42 +7,42 @@ from . import record_ui_events -def test_hover_on_widget(uimanager): +def test_hover_on_widget(ui): # GIVEN widget = UIDummy() - uimanager.add(widget) + ui.add(widget) # WHEN - uimanager.move_mouse(widget.center_x, widget.center_y) + ui.move_mouse(widget.center_x, widget.center_y) # THEN assert widget.hovered is True -def test_overlapping_hover_on_widget(uimanager): +def test_overlapping_hover_on_widget(ui): # GIVEN widget1 = UIDummy() widget2 = UIDummy() - uimanager.add(widget1) - uimanager.add(widget2) + ui.add(widget1) + ui.add(widget2) # WHEN - uimanager.move_mouse(widget1.center_x, widget1.center_y) + ui.move_mouse(widget1.center_x, widget1.center_y) # THEN assert widget1.hovered is True assert widget2.hovered is True -def test_left_click_on_widget(uimanager): +def test_left_click_on_widget(ui): # GIVEN widget1 = UIDummy() widget1.on_click = Mock() - uimanager.add(widget1) + ui.add(widget1) # WHEN with record_ui_events(widget1, "on_event", "on_click") as records: - uimanager.click(widget1.center_x, widget1.center_y, button=arcade.MOUSE_BUTTON_LEFT) + ui.click(widget1.center_x, widget1.center_y, button=arcade.MOUSE_BUTTON_LEFT) # THEN records: List[UIEvent] @@ -61,15 +61,15 @@ def test_left_click_on_widget(uimanager): assert widget1.on_click.called -def test_ignores_right_click_on_widget(uimanager): +def test_ignores_right_click_on_widget(ui): # GIVEN widget1 = UIDummy() widget1.on_click = Mock() - uimanager.add(widget1) + ui.add(widget1) # WHEN with record_ui_events(widget1, "on_event", "on_click") as records: - uimanager.click(widget1.center_x, widget1.center_y, button=arcade.MOUSE_BUTTON_RIGHT) + ui.click(widget1.center_x, widget1.center_y, button=arcade.MOUSE_BUTTON_RIGHT) # THEN records: List[UIEvent] @@ -79,16 +79,16 @@ def test_ignores_right_click_on_widget(uimanager): assert not widget1.on_click.called -def test_click_on_widget_if_disabled(uimanager): +def test_click_on_widget_if_disabled(ui): # GIVEN widget1 = UIDummy() widget1.disabled = True widget1.on_click = Mock() - uimanager.add(widget1) + ui.add(widget1) # WHEN with record_ui_events(widget1, "on_event", "on_click") as records: - uimanager.click(widget1.center_x, widget1.center_y) + ui.click(widget1.center_x, widget1.center_y) # THEN records: List[UIEvent] @@ -99,17 +99,17 @@ def test_click_on_widget_if_disabled(uimanager): assert not widget1.on_click.called -def test_click_on_overlay_widget_consumes_events(uimanager): +def test_click_on_overlay_widget_consumes_events(ui): # GIVEN widget1 = UIDummy() widget2 = UIDummy() - uimanager.add(widget1) - uimanager.add(widget2) + ui.add(widget1) + ui.add(widget2) # WHEN with record_ui_events(widget1, "on_click") as w1_records: with record_ui_events(widget2, "on_click") as w2_records: - uimanager.click(widget1.center_x, widget1.center_y) + ui.click(widget1.center_x, widget1.center_y) # THEN # events are consumed before they get to underlying widget @@ -126,17 +126,17 @@ def test_click_on_overlay_widget_consumes_events(uimanager): assert click_event.y == widget2.center_y -def test_click_consumed_by_nested_widget(uimanager): +def test_click_consumed_by_nested_widget(ui): # GIVEN widget1 = UIDummy() widget2 = UIDummy() widget1.add(widget2) - uimanager.add(widget1) + ui.add(widget1) # WHEN with record_ui_events(widget1, "on_click") as w1_records: with record_ui_events(widget2, "on_click") as w2_records: - uimanager.click(widget1.center_x, widget1.center_y) + ui.click(widget1.center_x, widget1.center_y) # THEN # events are consumed before they get to underlying widget diff --git a/tests/unit/gui/test_layouting_gridlayout.py b/tests/unit/gui/test_layouting_gridlayout.py index 3fe840a68..281f0b0bb 100644 --- a/tests/unit/gui/test_layouting_gridlayout.py +++ b/tests/unit/gui/test_layouting_gridlayout.py @@ -1,10 +1,11 @@ +from pyglet.math import Vec2 + from arcade import LBWH -from arcade.gui import UIDummy, UIManager, UIBoxLayout, UIAnchorLayout +from arcade.gui import UIAnchorLayout, UIBoxLayout, UIDummy, UIManager from arcade.gui.widgets.layout import UIGridLayout -from pyglet.math import Vec2 -def test_place_widget(window): +def test_place_widget(ui): dummy1 = UIDummy(width=100, height=100) dummy2 = UIDummy(width=100, height=100) dummy3 = UIDummy(width=100, height=100) @@ -17,8 +18,8 @@ def test_place_widget(window): subject.add(dummy3, 1, 0) subject.add(dummy4, 1, 1) - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() + ui.add(subject) + ui.execute_layout() # check that do_layout doesn't manipulate the rect assert subject.rect == LBWH(0, 0, 200, 200) @@ -29,15 +30,15 @@ def test_place_widget(window): assert dummy4.position == Vec2(100, 0) -def test_can_handle_empty_cells(window): +def test_can_handle_empty_cells(ui): dummy1 = UIDummy(width=100, height=100) subject = UIGridLayout(column_count=2, row_count=2) subject.add(dummy1, 0, 0) - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() + ui.add(subject) + ui.execute_layout() # check that do_layout doesn't manipulate the rect assert subject.rect == LBWH(0, 0, 100, 100) @@ -45,7 +46,7 @@ def test_can_handle_empty_cells(window): assert dummy1.position == Vec2(0, 0) -def test_place_widget_with_different_sizes(window): +def test_place_widget_with_different_sizes(ui): dummy1 = UIDummy(width=50, height=100) dummy2 = UIDummy(width=100, height=100) dummy3 = UIDummy(width=100, height=50) @@ -58,8 +59,8 @@ def test_place_widget_with_different_sizes(window): subject.add(dummy3, 1, 0) subject.add(dummy4, 1, 1) - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() + ui.add(subject) + ui.execute_layout() assert subject.rect == LBWH(0, 0, 200, 200) @@ -69,22 +70,21 @@ def test_place_widget_with_different_sizes(window): assert dummy4.position == Vec2(125, 25) -def test_place_widget_within_content_rect(window): +def test_place_widget_within_content_rect(ui): dummy1 = UIDummy(width=100, height=100) subject = UIGridLayout(column_count=1, row_count=1).with_padding(left=10, bottom=20) subject.add(dummy1, 0, 0) - assert subject.size_hint_min == (110, 120) - - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() + ui.add(subject) + ui.execute_layout() + assert subject.size_hint_min == (110, 120) assert dummy1.position == Vec2(10, 20) -def test_place_widgets_with_col_row_span(window): +def test_place_widgets_with_col_row_span(ui): dummy1 = UIDummy(width=100, height=100) dummy2 = UIDummy(width=100, height=100) dummy3 = UIDummy(width=100, height=100) @@ -104,8 +104,8 @@ def test_place_widgets_with_col_row_span(window): subject.add(dummy5, 0, 2, column_span=2) subject.add(dummy6, 2, 0, row_span=3) - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() + ui.add(subject) + ui.execute_layout() assert dummy1.position == Vec2(0, 200) assert dummy2.position == Vec2(0, 100) @@ -115,7 +115,20 @@ def test_place_widgets_with_col_row_span(window): assert dummy6.position == Vec2(200, 50) -def test_place_widgets_with_col_row_span_and_spacing(window): +def test_place_widgets_with_col_row_span_and_spacing(ui): + """ + col1 col2 + +-----+-----+ + | 1 | 2 | + +-----+-----+ + | 3 | 4 | + +-----+-----+ + | 6 | + +-----+-----+ + + col1 width: 100 + col2 width: 100 + """ dummy1 = UIDummy(width=100, height=100) dummy2 = UIDummy(width=100, height=100) dummy3 = UIDummy(width=100, height=100) @@ -129,22 +142,23 @@ def test_place_widgets_with_col_row_span_and_spacing(window): ) subject.add(dummy1, 0, 0) - subject.add(dummy2, 0, 1) - subject.add(dummy3, 1, 0) + subject.add(dummy2, 1, 0) + subject.add(dummy3, 0, 1) subject.add(dummy4, 1, 1) subject.add(dummy5, 0, 2, column_span=2) - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() + ui.add(subject) + ui.execute_layout() - assert dummy1.position == Vec2(10, 200) - assert dummy2.position == Vec2(10, 100) - assert dummy3.position == Vec2(130, 200) - assert dummy4.position == Vec2(130, 100) - assert dummy5.position == Vec2(10, 0) + assert subject.rect.size == (220, 300) + assert dummy1.position == Vec2(0, 200) + assert dummy2.position == Vec2(120, 200) + assert dummy3.position == Vec2(0, 100) + assert dummy4.position == Vec2(120, 100) + assert dummy5.position == Vec2(0, 0) -def test_fit_content_by_default(window): +def test_fit_content_by_default(ui): subject = UIGridLayout( column_count=1, row_count=1, @@ -153,11 +167,13 @@ def test_fit_content_by_default(window): assert subject.size_hint == (0, 0) -def test_adjust_children_size_relative(window): - dummy1 = UIDummy(width=100, height=100) - dummy2 = UIDummy(width=50, height=50, size_hint=(0.75, 0.75)) - dummy3 = UIDummy(width=100, height=100, size_hint=(0.5, 0.5), size_hint_min=(60, 60)) - dummy4 = UIDummy(width=100, height=100) +def test_adjust_children_size_relative(ui): + dummy1 = UIDummy(width=50, height=50) # fix size + dummy2 = UIDummy(width=50, height=50, size_hint=(0.75, 0.75)) # shrinks + dummy3 = UIDummy( + width=100, height=100, size_hint=(0.3, 0.3), size_hint_min=(60, 60) + ) # shrinks to 60,60 + dummy4 = UIDummy(width=10, height=10) # fix size subject = UIGridLayout( column_count=2, @@ -169,19 +185,20 @@ def test_adjust_children_size_relative(window): subject.add(dummy3, 1, 0) subject.add(dummy4, 1, 1) - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() - - # check that do_layout doesn't manipulate the rect - assert subject.rect == LBWH(0, 0, 200, 200) + ui.add(subject) + ui.execute_layout() - assert dummy1.size == Vec2(100, 100) - assert dummy2.size == Vec2(75, 75) + assert subject.rect.size == (110, 70) + assert dummy1.size == Vec2(50, 50) + assert dummy2.size == Vec2( + 50, # width: 75% of 110 is 82, but cell is only 50, so it should be 50 + 10, # height: 75% of 70 is 52, but cell is only 10, so it should be 10 + ) assert dummy3.size == Vec2(60, 60) - assert dummy4.size == Vec2(100, 100) + assert dummy4.size == Vec2(10, 10) -def test_does_not_adjust_children_without_size_hint(window): +def test_does_not_adjust_children_without_size_hint(ui): dummy1 = UIDummy(width=100, height=100) dummy2 = UIDummy(width=50, height=50, size_hint=(0.75, None)) dummy3 = UIDummy(width=50, height=50, size_hint=(None, 0.75)) @@ -197,19 +214,19 @@ def test_does_not_adjust_children_without_size_hint(window): subject.add(dummy3, 1, 0) subject.add(dummy4, 1, 1) - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() + ui.add(subject) + ui.execute_layout() # check that do_layout doesn't manipulate the rect assert subject.rect == LBWH(0, 0, 200, 200) assert dummy1.size == Vec2(100, 100) - assert dummy2.size == Vec2(75, 50) - assert dummy3.size == Vec2(50, 75) + assert dummy2.size == Vec2(100, 50) + assert dummy3.size == Vec2(50, 100) assert dummy4.size == Vec2(100, 100) -def test_size_hint_and_spacing(window): +def test_size_hint_and_spacing(ui): dummy1 = UIDummy(width=100, height=100) subject = UIGridLayout( @@ -221,8 +238,8 @@ def test_size_hint_and_spacing(window): subject.add(dummy1, 0, 0) - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() + ui.add(subject) + ui.execute_layout() assert dummy1.size == Vec2(100, 100) @@ -230,7 +247,7 @@ def test_size_hint_and_spacing(window): assert dummy1.size == Vec2(100, 100) -def test_empty_cells(window): +def test_empty_cells(ui): dummy1 = UIDummy(width=100, height=100) subject = UIGridLayout( @@ -240,14 +257,13 @@ def test_empty_cells(window): subject.add(dummy1, 2, 2) - subject.rect = LBWH(0, 0, *subject.size_hint_min) - subject.do_layout() + ui.add(subject) + ui.execute_layout() assert dummy1.position == Vec2(0, 0) -def test_nested_grid_layouts(window): - ui = UIManager() +def test_nested_grid_layouts(ui): outer = UIGridLayout(row_count=1, column_count=1) inner = UIGridLayout(row_count=1, column_count=1) @@ -261,8 +277,7 @@ def test_nested_grid_layouts(window): assert outer.rect.size == Vec2(100, 100) -def test_nested_box_layouts(window): - ui = UIManager() +def test_nested_box_layouts(ui): outer = UIGridLayout(row_count=1, column_count=1) inner = UIBoxLayout() @@ -276,8 +291,7 @@ def test_nested_box_layouts(window): assert outer.rect.size == Vec2(100, 100) -def test_nested_anchor_layouts(window): - ui = UIManager() +def test_nested_anchor_layouts(ui): outer = UIGridLayout(row_count=1, column_count=1) inner = UIAnchorLayout(size_hint_min=(100, 100)) @@ -290,8 +304,7 @@ def test_nested_anchor_layouts(window): assert outer.rect.size == Vec2(100, 100) -def test_update_size_hint_min_on_child_size_change(window): - ui = UIManager() +def test_update_size_hint_min_on_child_size_change(ui): grid = UIGridLayout(row_count=1, column_count=1) dummy = UIDummy(size_hint_min=(100, 100), size_hint=(0, 0)) @@ -303,3 +316,67 @@ def test_update_size_hint_min_on_child_size_change(window): assert dummy.rect.size == Vec2(200, 200) assert grid.rect.size == Vec2(200, 200) + + +def test_widgets_are_centered(ui): + # grid elements not centered + # https://github.com/pythonarcade/arcade/issues/2210 + grid = UIGridLayout(row_count=1, column_count=1, horizontal_spacing=10, vertical_spacing=10) + ui.add(grid) + + dummy1 = UIDummy(width=100, height=100) + grid.add(dummy1, 0, 0) + + ui.execute_layout() + + assert dummy1.rect.bottom_left == Vec2(0, 0) + + +def test_size_hint_none(ui): + # size changed when sh None + grid = UIGridLayout(row_count=1, column_count=1, width=150, height=150, size_hint=None) + ui.add(grid) + + dummy1 = UIDummy(width=100, height=100, size_hint_max=(150, None)) + grid.add(dummy1, 0, 0) + + ui.execute_layout() + + assert dummy1.rect.size == Vec2(100, 100) + + +def test_minimal_size(ui): + grid = ui.add( + UIGridLayout( + column_count=3, + row_count=1, + size_hint=(0, 0), + ) + ) + + grid.add(UIDummy(width=200, height=100), column=0, row=0, column_span=2) + grid.add(UIDummy(width=100, height=100), column=2, row=0, row_span=1) + + ui.execute_layout() + + assert grid.size == (300, 100) + assert grid.size_hint_min == (300, 100) + + +def test_calculate_size_hint_min(ui): + dummy1 = UIDummy(width=50, height=100) + dummy2 = UIDummy(width=100, height=100) + dummy3 = UIDummy(width=100, height=50) + dummy4 = UIDummy(width=50, height=50) + + subject = UIGridLayout(column_count=2, row_count=2) + + subject.add(dummy1, 0, 0) + subject.add(dummy2, 0, 1) + subject.add(dummy3, 1, 0) + subject.add(dummy4, 1, 1) + + ui.add(subject) + ui.execute_layout() + + assert subject.size_hint_min == (200, 200) diff --git a/tests/unit/gui/test_uilabel.py b/tests/unit/gui/test_uilabel.py index 7363a1a83..f6675d306 100644 --- a/tests/unit/gui/test_uilabel.py +++ b/tests/unit/gui/test_uilabel.py @@ -11,7 +11,9 @@ def test_constructor_only_text_no_size(window): """Should fit text""" label = UILabel(text="Example") - assert label.rect.width == pytest.approx(63, abs=7) # on windows the width differs about 6 pixel + assert label.rect.width == pytest.approx( + 63, abs=7 + ) # on windows the width differs about 6 pixel assert label.rect.height == pytest.approx(19, abs=1) @@ -177,7 +179,7 @@ def test_multiline_enabled_size_hint_min_adapts_to_new_text(window): assert label.size_hint_min[1] > shm_h -def test_integration_with_layout_fit_to_content(uimanager): +def test_integration_with_layout_fit_to_content(ui): """Tests multiple integrations with layout/uimanager and auto size. Just to be sure, it really works as expected. @@ -187,8 +189,8 @@ def test_integration_with_layout_fit_to_content(uimanager): size_hint=(0, 0), # default, enables auto size ) - uimanager.add(label) - uimanager.execute_layout() + ui.add(label) + ui.execute_layout() # auto size should fit the text assert label.rect.width == pytest.approx(63, abs=7) @@ -196,7 +198,7 @@ def test_integration_with_layout_fit_to_content(uimanager): # even when text changed label.text = "Example, which is way longer" - uimanager.execute_layout() + ui.execute_layout() assert label.rect.width > 63 assert label.rect.height == pytest.approx(19, abs=6) @@ -204,13 +206,13 @@ def test_integration_with_layout_fit_to_content(uimanager): # or font label.text = "Example" label.update_font(font_size=20) - uimanager.execute_layout() + ui.execute_layout() assert label.rect.width > 63 assert label.rect.height > 20 -def test_fit_content_overrides_width(uimanager): +def test_fit_content_overrides_width(ui): label = UILabel( text="Example", width=100, @@ -223,7 +225,7 @@ def test_fit_content_overrides_width(uimanager): assert label.rect.height == pytest.approx(19, abs=6) -def test_fit_content_uses_adaptive_multiline_width(uimanager): +def test_fit_content_uses_adaptive_multiline_width(ui): label = UILabel( text="Example with multiline enabled", width=70, diff --git a/tests/unit/gui/test_uimanager_callbacks.py b/tests/unit/gui/test_uimanager_callbacks.py index 199ae47a4..edfa10293 100644 --- a/tests/unit/gui/test_uimanager_callbacks.py +++ b/tests/unit/gui/test_uimanager_callbacks.py @@ -13,11 +13,11 @@ from . import record_ui_events -def test_on_mouse_press_passes_an_event(uimanager): - uimanager.add(UIDummy()) +def test_on_mouse_press_passes_an_event(ui): + ui.add(UIDummy()) - with record_ui_events(uimanager, "on_event") as records: - uimanager.on_mouse_press(1, 2, 3, 4) + with record_ui_events(ui, "on_event") as records: + ui.on_mouse_press(1, 2, 3, 4) event = records[-1] assert isinstance(event, UIMousePressEvent) @@ -27,11 +27,11 @@ def test_on_mouse_press_passes_an_event(uimanager): assert event.modifiers == 4 -def test_on_mouse_release_passes_an_event(uimanager): - uimanager.add(UIDummy()) +def test_on_mouse_release_passes_an_event(ui): + ui.add(UIDummy()) - with record_ui_events(uimanager, "on_event") as records: - uimanager.on_mouse_release(1, 2, 3, 4) + with record_ui_events(ui, "on_event") as records: + ui.on_mouse_release(1, 2, 3, 4) event = records[-1] assert isinstance(event, UIMouseReleaseEvent) @@ -41,11 +41,11 @@ def test_on_mouse_release_passes_an_event(uimanager): assert event.modifiers == 4 -def test_on_mouse_scroll_passes_an_event(uimanager): - uimanager.add(UIDummy()) +def test_on_mouse_scroll_passes_an_event(ui): + ui.add(UIDummy()) - with record_ui_events(uimanager, "on_event") as records: - uimanager.on_mouse_scroll(1, 2, 3, 4) + with record_ui_events(ui, "on_event") as records: + ui.on_mouse_scroll(1, 2, 3, 4) event = records[-1] assert isinstance(event, UIMouseScrollEvent) @@ -55,11 +55,11 @@ def test_on_mouse_scroll_passes_an_event(uimanager): assert event.scroll_y == 4 -def test_on_mouse_motion_passes_an_event(uimanager): - uimanager.add(UIDummy()) +def test_on_mouse_motion_passes_an_event(ui): + ui.add(UIDummy()) - with record_ui_events(uimanager, "on_event") as records: - uimanager.on_mouse_motion(1, 2, 3, 4) + with record_ui_events(ui, "on_event") as records: + ui.on_mouse_motion(1, 2, 3, 4) event = records[-1] assert isinstance(event, UIMouseMovementEvent) @@ -69,11 +69,11 @@ def test_on_mouse_motion_passes_an_event(uimanager): assert event.dy == 4 -def test_on_key_press_passes_an_event(uimanager): - uimanager.add(UIDummy()) +def test_on_key_press_passes_an_event(ui): + ui.add(UIDummy()) - with record_ui_events(uimanager, "on_event") as records: - uimanager.on_key_press(arcade.key.ENTER, 0) + with record_ui_events(ui, "on_event") as records: + ui.on_key_press(arcade.key.ENTER, 0) event = records[-1] assert isinstance(event, UIKeyPressEvent) @@ -81,11 +81,11 @@ def test_on_key_press_passes_an_event(uimanager): assert event.modifiers == 0 -def test_on_key_release_passes_an_event(uimanager): - uimanager.add(UIDummy()) +def test_on_key_release_passes_an_event(ui): + ui.add(UIDummy()) - with record_ui_events(uimanager, "on_event") as records: - uimanager.on_key_release(arcade.key.ENTER, 0) + with record_ui_events(ui, "on_event") as records: + ui.on_key_release(arcade.key.ENTER, 0) event = records[-1] assert isinstance(event, UIKeyReleaseEvent) @@ -93,33 +93,33 @@ def test_on_key_release_passes_an_event(uimanager): assert event.modifiers == 0 -def test_on_text_passes_an_event(uimanager): - uimanager.add(UIDummy()) +def test_on_text_passes_an_event(ui): + ui.add(UIDummy()) - with record_ui_events(uimanager, "on_event") as records: - uimanager.on_text("a") + with record_ui_events(ui, "on_event") as records: + ui.on_text("a") event = records[-1] assert isinstance(event, UITextInputEvent) assert event.text == "a" -def test_on_text_motion_passes_an_event(uimanager): - uimanager.add(UIDummy()) +def test_on_text_motion_passes_an_event(ui): + ui.add(UIDummy()) - with record_ui_events(uimanager, "on_event") as records: - uimanager.on_text_motion(MOTION_UP) + with record_ui_events(ui, "on_event") as records: + ui.on_text_motion(MOTION_UP) event = records[-1] assert isinstance(event, UITextMotionEvent) assert event.motion == MOTION_UP -def test_on_text_motion_selection_passes_an_event(uimanager): - uimanager.add(UIDummy()) +def test_on_text_motion_selection_passes_an_event(ui): + ui.add(UIDummy()) - with record_ui_events(uimanager, "on_event") as records: - uimanager.on_text_motion_select(MOTION_UP) + with record_ui_events(ui, "on_event") as records: + ui.on_text_motion_select(MOTION_UP) event = records[-1] assert isinstance(event, UITextMotionSelectEvent) diff --git a/tests/unit/gui/test_uimanager_camera.py b/tests/unit/gui/test_uimanager_camera.py index cb21b6062..93a67b734 100644 --- a/tests/unit/gui/test_uimanager_camera.py +++ b/tests/unit/gui/test_uimanager_camera.py @@ -5,58 +5,58 @@ from arcade.gui import UIFlatButton -def test_ui_manager_respects_window_camera(uimanager, window): +def test_ui_manager_respects_window_camera(ui, window): # GIVEN in_game_cam = arcade.Camera2D(viewport=LBWH(100, 100, window.width, window.height)) - button = uimanager.add(UIFlatButton(text="BottomLeftButton", width=100, height=100)) + button = ui.add(UIFlatButton(text="BottomLeftButton", width=100, height=100)) button.on_click = Mock() # WHEN in_game_cam.use() - uimanager.click(50, 50) + ui.click(50, 50) # THEN assert button.on_click.called -def test_ui_manager_use_positioned_camera(uimanager, window): +def test_ui_manager_use_positioned_camera(ui, window): # GIVEN - button = uimanager.add(UIFlatButton(text="BottomLeftButton", width=100, height=100)) + button = ui.add(UIFlatButton(text="BottomLeftButton", width=100, height=100)) button.on_click = Mock() # WHEN # this moves the camera bottom left and UI elements are shown more to the top right - uimanager.camera.bottom_left = (-100, -100) - uimanager.click(150, 150) + ui.camera.bottom_left = (-100, -100) + ui.click(150, 150) # THEN assert button.on_click.called -def test_ui_manager_use_rotated_camera(uimanager, window): +def test_ui_manager_use_rotated_camera(ui, window): # GIVEN - button = uimanager.add(UIFlatButton(text="BottomLeftButton", width=100, height=100)) + button = ui.add(UIFlatButton(text="BottomLeftButton", width=100, height=100)) button.on_click = Mock() # WHEN - uimanager.camera.angle = 90 - x, y = uimanager.camera.project((50, 50)) - uimanager.click(x, y) + ui.camera.angle = 90 + x, y = ui.camera.project((50, 50)) + ui.click(x, y) # THEN - assert button.on_click.called, (uimanager.camera.project((50, 50)), window.size) + assert button.on_click.called, (ui.camera.project((50, 50)), window.size) -def test_ui_manager_use_zoom_camera(uimanager, window): +def test_ui_manager_use_zoom_camera(ui, window): # GIVEN - button = uimanager.add(UIFlatButton(text="BottomLeftButton", width=100, height=100)) + button = ui.add(UIFlatButton(text="BottomLeftButton", width=100, height=100)) button.on_click = Mock() # WHEN - uimanager.camera.zoom = 0.9 - x, y = uimanager.camera.project((50, 50)) - uimanager.click(x, y) + ui.camera.zoom = 0.9 + x, y = ui.camera.project((50, 50)) + ui.click(x, y) # THEN assert button.on_click.called diff --git a/tests/unit/gui/test_uislider.py b/tests/unit/gui/test_uislider.py index dd8affb2a..8c4e5b415 100644 --- a/tests/unit/gui/test_uislider.py +++ b/tests/unit/gui/test_uislider.py @@ -11,17 +11,17 @@ def test_initial_value_set(): assert slider.value == 0 -def test_change_value_on_drag(uimanager): +def test_change_value_on_drag(ui): # GIVEN slider = UISlider(height=30, width=120) - uimanager.add(slider) + ui.add(slider) assert slider.value == 0 # WHEN cx, cy = slider._thumb_x, slider.rect.y - uimanager.click_and_hold(cx, cy) - uimanager.drag(cx + 20, cy) + ui.click_and_hold(cx, cy) + ui.drag(cx + 20, cy) # THEN assert slider.value == 20 diff --git a/tests/unit/gui/test_widget_inputtext.py b/tests/unit/gui/test_widget_inputtext.py index 0efa36b71..55f4eddb6 100644 --- a/tests/unit/gui/test_widget_inputtext.py +++ b/tests/unit/gui/test_widget_inputtext.py @@ -1,7 +1,7 @@ from arcade.gui import UIInputText, UIOnChangeEvent -def test_deactivated_by_default(uimanager): +def test_deactivated_by_default(ui): # GIVEN widget = UIInputText() @@ -9,60 +9,60 @@ def test_deactivated_by_default(uimanager): assert widget.active is False -def test_activated_after_click(uimanager): +def test_activated_after_click(ui): # GIVEN widget = UIInputText() - uimanager.add(widget) + ui.add(widget) # WHEN - uimanager.click(*widget.rect.center) + ui.click(*widget.rect.center) # THEN assert widget.active is True -def test_deactivated_after_off_click(uimanager): +def test_deactivated_after_off_click(ui): # GIVEN widget = UIInputText() - uimanager.add(widget) + ui.add(widget) widget.activate() # WHEN - uimanager.click(200, 200) + ui.click(200, 200) # THEN assert widget.active is False -def test_captures_text_when_active(uimanager): +def test_captures_text_when_active(ui): # GIVEN widget = UIInputText() - uimanager.add(widget) + ui.add(widget) widget.activate() # WHEN - uimanager.type_text("Hello") + ui.type_text("Hello") # THEN assert widget.text == "Hello" -def test_does_not_capture_text_when_inactive(uimanager): +def test_does_not_capture_text_when_inactive(ui): # GIVEN widget = UIInputText() - uimanager.add(widget) + ui.add(widget) # WHEN - uimanager.type_text("Hello") + ui.type_text("Hello") # THEN assert widget.text == "" -def test_dispatches_on_change_event(uimanager): +def test_dispatches_on_change_event(ui): # GIVEN widget = UIInputText() - uimanager.add(widget) + ui.add(widget) recorded = [] @@ -72,7 +72,7 @@ def on_change(event): # WHEN widget.activate() - uimanager.type_text("Hello") + ui.type_text("Hello") # THEN assert len(recorded) == 1 @@ -82,10 +82,10 @@ def on_change(event): assert recorded_event.new_value == "Hello" -def test_setting_text_dispatches_on_change_event(uimanager): +def test_setting_text_dispatches_on_change_event(ui): # GIVEN widget = UIInputText() - uimanager.add(widget) + ui.add(widget) recorded = []