diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index a7108276ca..4b8e83bb3d 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations from abc import ABC +from math import floor from random import randint from typing import ( NamedTuple, @@ -12,6 +13,7 @@ Tuple, List, Dict, + Callable ) from pyglet.event import EventDispatcher, EVENT_HANDLED, EVENT_UNHANDLED @@ -31,7 +33,7 @@ from arcade.gui.nine_patch import NinePatchTexture from arcade.gui.property import Property, bind, ListProperty from arcade.gui.surface import Surface -from arcade.types import RGBA255, Color +from arcade.types import RGBA255, Color, Point, AsFloat if TYPE_CHECKING: from arcade.gui.ui_manager import UIManager @@ -52,66 +54,125 @@ class Rect(NamedTuple): width: float height: float - def move(self, dx: float = 0, dy: float = 0): + def move(self, dx: AsFloat = 0.0, dy: AsFloat = 0.0) -> "Rect": """Returns new Rect which is moved by dx and dy""" - return Rect(self.x + dx, self.y + dy, self.width, self.height) + x, y, width, height = self + return Rect(x + dx, y + dy, width, height) - def collide_with_point(self, x, y): + def collide_with_point(self, x: AsFloat, y: AsFloat) -> bool: + """Return true if ``x`` and ``y`` are within this rect. + + This check is inclusive. Values on the :py:attr:`.left`, + :py:attr:`.right`, :py:attr:`.top`, and :py:attr:`.bottom` + edges will be counted as inside the rect. + + .. code-block:: python + + >>> bounds = Rect(0.0, 0.0, 5.0, 5.0) + >>> bounds.collide_with_point(0.0, 0.0) + True + >>> bounds.collide_with_point(5.0, 5.0) + True + + :param x: The x value to check as inside the rect. + :param y: The y value to check as inside the rect. + """ left, bottom, width, height = self return left <= x <= left + width and bottom <= y <= bottom + height - def scale(self, scale: float) -> "Rect": - """Returns a new rect with scale applied""" + def scale( + self, + scale: float, + rounding: Optional[Callable[..., float]] = floor + ) -> "Rect": + """Return a new rect scaled relative to the origin. + + By default, the new rect's values are rounded down to whole + values. You can alter this by passing a different rounding + behavior: + + * Pass ``None`` to skip rounding + * Pass a function which takes a number and returns a float + to choose rounding behavior. + + :param scale: A scale factor. + :param rounding: ``None`` or a callable specifying how to + round the scaled values. + """ + x, y, width, height = self + if rounding is not None: + return Rect( + rounding(x * scale), + rounding(y * scale), + rounding(width * scale), + rounding(height * scale), + ) return Rect( - int(self.x * scale), - int(self.y * scale), - int(self.width * scale), - int(self.height * scale), + x * scale, + y * scale, + width * scale, + height * scale, ) - def resize(self, width=None, height=None): - """ - Returns a rect with changed width and height. + def resize( + self, + width: float | None = None, + height: float | None = None + ) -> "Rect": + """Return a rect with a new width or height but same lower left. + Fix x and y coordinate. + :param width: A width for the new rectangle. + :param height: A height for the new rectangle. """ width = width if width is not None else self.width height = height if height is not None else self.height return Rect(self.x, self.y, width, height) @property - def size(self): + def size(self) -> Tuple[float, float]: + """Read-only pixel size of the rect. + + Since these rects are immutable, use helper instance methods to + get updated rects. For example, :py:meth:`.resize` may be what + you're looking for. + """ return self.width, self.height @property - def left(self): + def left(self) -> float: + """The left edge on the X axis.""" return self.x @property - def right(self): + def right(self) -> float: + """The right edge on the X axis.""" return self.x + self.width @property - def bottom(self): + def bottom(self) -> float: + """The bottom edge on the Y axis.""" return self.y @property - def top(self): + def top(self) -> float: + """The top edge on the Y axis.""" return self.y + self.height @property - def center_x(self): + def center_x(self) -> float: return self.x + self.width / 2 @property - def center_y(self): + def center_y(self) -> float: return self.y + self.height / 2 @property - def center(self): + def center(self) -> Point: return self.center_x, self.center_y @property - def position(self): + def position(self) -> Point: """Bottom left coordinates""" return self.left, self.bottom @@ -130,28 +191,32 @@ def align_left(self, value: float) -> "Rect": diff_x = value - self.left return self.move(dx=diff_x) - def align_right(self, value: float) -> "Rect": + def align_right(self, value: AsFloat) -> "Rect": """Returns new Rect, which is aligned to the right""" diff_x = value - self.right return self.move(dx=diff_x) - def align_center(self, center_x, center_y): + def align_center(self, center_x: AsFloat, center_y: AsFloat) -> "Rect": """Returns new Rect, which is aligned to the center x and y""" diff_x = center_x - self.center_x diff_y = center_y - self.center_y return self.move(dx=diff_x, dy=diff_y) - def align_center_x(self, value: float) -> "Rect": + def align_center_x(self, value: AsFloat) -> "Rect": """Returns new Rect, which is aligned to the center_x""" diff_x = value - self.center_x return self.move(dx=diff_x) - def align_center_y(self, value: float) -> "Rect": + def align_center_y(self, value: AsFloat) -> "Rect": """Returns new Rect, which is aligned to the center_y""" diff_y = value - self.center_y return self.move(dy=diff_y) - def min_size(self, width=None, height=None): + def min_size( + self, + width: Optional[AsFloat] = None, + height: Optional[AsFloat] = None + ) -> "Rect": """ Sets the size to at least the given min values. """ @@ -162,19 +227,23 @@ def min_size(self, width=None, height=None): max(height or 0.0, self.height), ) - def max_size(self, width: Optional[float] = None, height: Optional[float] = None): + def max_size( + self, + width: Optional[AsFloat] = None, + height: Optional[AsFloat] = None + ) -> "Rect": """ Limits the size to the given max values. """ - w, h = self.size - if width: - w = min(width, self.width) - if height: - h = min(height, self.height) + x, y, w, h = self + if width is not None: + w = min(width, w) + if height is not None: + h = min(height, h) - return Rect(self.x, self.y, w, h) + return Rect(x, y, w, h) - def union(self, rect: "Rect"): + def union(self, rect: "Rect") -> "Rect": """ Returns a new Rect that is the union of this rect and another. The union is the smallest rectangle that contains theses two rectangles. diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index 91d42b2373..b6633825df 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -176,8 +176,13 @@ def do_layout(self): self._default_button.rect = self.rect # resize layout to contain widgets + overlay = self._overlay + rect = overlay.rect + if overlay.size_hint_min is not None: + rect = rect.resize(*overlay.size_hint_min) + self._overlay.rect = ( - self._overlay.rect.resize(*self._overlay.size_hint_min) + rect .align_top(self.bottom - 2) .align_left(self._default_button.left) ) diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 0e904c0042..e35a95eb28 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -351,7 +351,7 @@ def __init__( ) self.layout = pyglet.text.layout.IncrementalTextLayout( - self.doc, width - self.LAYOUT_OFFSET, height, multiline=multiline + self.doc, int(width - self.LAYOUT_OFFSET), int(height), multiline=multiline ) self.layout.x += self.LAYOUT_OFFSET self.caret = Caret(self.layout, color=Color.from_iterable(caret_color)) @@ -431,8 +431,8 @@ def _update_layout(self): if layout_size != self.content_size: layout.begin_update() - layout.width = self.content_width - self.LAYOUT_OFFSET - layout.height = self.content_height + layout.width = int(self.content_width - self.LAYOUT_OFFSET) + layout.height = int(self.content_height) layout.end_update() @property @@ -522,8 +522,8 @@ def __init__( self.layout = pyglet.text.layout.ScrollableTextLayout( self.doc, - width=self.content_width, - height=self.content_height, + width=int(self.content_width), + height=int(self.content_height), multiline=multiline, ) @@ -552,12 +552,14 @@ def text(self, value): def _update_layout(self): # Update Pyglet layout size layout = self.layout - layout_size = layout.width, layout.height - if layout_size != self.content_size: + # Convert from local float coords to ints to avoid jitter + # since pyglet imposes int-only coordinates as of pyglet 2.0 + content_width, content_height = map(int, self.content_size) + if content_width != layout.width or content_height != layout.height: layout.begin_update() - layout.width = self.content_width - layout.height = self.content_height + layout.width = content_width + layout.height = content_height layout.end_update() def do_render(self, surface: Surface): diff --git a/arcade/text.py b/arcade/text.py index 89fa7b2b14..e929296ac2 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -442,28 +442,28 @@ def content_height(self) -> int: return self._label.content_height @property - def left(self) -> int: + def left(self) -> float: """ Pixel location of the left content border. """ return self._label.left @property - def right(self) -> int: + def right(self) -> float: """ Pixel location of the right content border. """ return self._label.right @property - def top(self) -> int: + def top(self) -> float: """ Pixel location of the top content border. """ return self._label.top @property - def bottom(self) -> int: + def bottom(self) -> float: """ Pixel location of the bottom content border. """ diff --git a/arcade/types.py b/arcade/types.py index 5d9de3da0d..36f2dc8db0 100644 --- a/arcade/types.py +++ b/arcade/types.py @@ -34,6 +34,11 @@ if TYPE_CHECKING: from arcade.texture import Texture + +#: 1. Makes pyright happier while also telling readers +#: 2. Tells readers we're converting any ints to floats +AsFloat = Union[float, int] + MAX_UINT24 = 0xFFFFFF MAX_UINT32 = 0xFFFFFFFF diff --git a/tests/unit/gui/test_rect.py b/tests/unit/gui/test_rect.py index 68ec78578b..751ff3faef 100644 --- a/tests/unit/gui/test_rect.py +++ b/tests/unit/gui/test_rect.py @@ -1,3 +1,5 @@ +from math import ceil + from arcade.gui.widgets import Rect @@ -175,3 +177,30 @@ def test_collide_with_point(): assert rect.collide_with_point(50, 50) assert rect.collide_with_point(100, 100) assert not rect.collide_with_point(150, 150) + + +def test_rect_scale(): + rect = Rect(0, 0, 95, 99) + + # Default rounding rounds down + assert rect.scale(0.9) == (0,0, 85, 89) + + # Passing in a rounding technique works too + assert rect.scale(0.9, rounding=ceil) == (0, 0, 86, 90) + + # Passing in None applies no rounding + rect_100 = Rect(100,100,100,100) + rect_100_scaled = rect_100.scale(0.1234, None) + assert rect_100_scaled == (12.34, 12.34, 12.34, 12.34) + assert rect_100_scaled.x == 12.34 + assert rect_100_scaled.y == 12.34 + assert rect_100_scaled.width == 12.34 + assert rect_100_scaled.height == 12.34 + + # Passing in None via rounding keyword applies no rounding + rect_100_scaled = rect_100.scale(0.1234, rounding=None) + assert rect_100_scaled == (12.34, 12.34, 12.34, 12.34) + assert rect_100_scaled.x == 12.34 + assert rect_100_scaled.y == 12.34 + assert rect_100_scaled.width == 12.34 + assert rect_100_scaled.height == 12.34