diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee2958e48..f0f55a5af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Version 3.1 (unreleased) - Drop Python 3.9 support +- Disable shadow window on all platforms to provide a consistent experience +- Performance + - Improved performance of `arcade.SpriteList.remove()` and `arcade.SpriteList.pop()` + - Improved performance of `arcade.hitbox.Hitbox.get_adjusted_points()` ~35% + - Improved performance of `arcade.SpriteList.draw_hit_boxes()` ~20x +- GUI + - `arcade.gui.widgets.text.UIInputText` + - now supports styles for `normal`, `disabled`, `hovered`, `pressed` and `invalid` states + - provides a `invalid` property to indicate if the input is invalid + - Added experimental `arcade.gui.experimental.UIRestrictedInput` + a subclass of `UIInputText` that restricts the input to a specific set of characters + - `arcade.gui.NinePatchTexture` is now lazy and can be created before a window exists allowing creation during imports. + - Improve `arcade.gui.experimental.scroll_area.ScrollBar` behavior to match HTML scrollbars +- Support drawing hitboxes using RBG or RGBA ## Version 3.0.2 diff --git a/arcade/examples/gui/2_widgets.py b/arcade/examples/gui/2_widgets.py index 7a15819d55..7cf71828ce 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -34,6 +34,7 @@ UITextureToggle, UIView, ) +from arcade.gui.experimental import UIPasswordInput # Load system fonts arcade.resources.load_kenney_fonts() @@ -256,14 +257,14 @@ def _show_text_widgets(self): self._body.clear() - box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left") + box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left", space_between=10) self._body.add(box) box.add(UILabel("Text Widgets", font_name=DEFAULT_FONT, font_size=32)) box.add(UISpace(size_hint=(1, 0.1))) row_1 = UIBoxLayout(vertical=False, size_hint=(1, 0.1)) box.add(row_1) - row_1.add(UILabel("Name: ", font_name=DEFAULT_FONT, font_size=24)) + row_1.add(UILabel("Username: ", font_name=DEFAULT_FONT, font_size=24)) name_input = row_1.add( UIInputText( width=400, @@ -274,12 +275,31 @@ def _show_text_widgets(self): border_width=2, ) ) + + row_2 = UIBoxLayout(vertical=False, size_hint=(1, 0.1)) + box.add(row_2) + row_2.add(UILabel("Password: ", font_name=DEFAULT_FONT, font_size=24)) + pw_input = row_2.add( + UIPasswordInput( + width=400, + height=40, + font_name=DEFAULT_FONT, + font_size=24, + border_color=arcade.uicolor.GRAY_CONCRETE, + border_width=2, + ) + ) + + @pw_input.event("on_change") + def _(event: UIOnChangeEvent): + event.source.invalid = event.new_value != "arcade" + welcome_label = box.add( UILabel("Nice to meet you ''", font_name=DEFAULT_FONT, font_size=24) ) @name_input.event("on_change") - def on_text_change(event: UIOnChangeEvent): + def _(event: UIOnChangeEvent): welcome_label.text = f"Nice to meet you `{event.new_value}`" box.add(UISpace(size_hint=(1, 0.3))) # Fill some of the left space diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index cfa37a460a..12b9b237be 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -1,3 +1,8 @@ +import warnings +from copy import deepcopy +from dataclasses import dataclass +from typing import Union + import pyglet from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from pyglet.text.caret import Caret @@ -5,6 +10,7 @@ from typing_extensions import Literal, override import arcade +from arcade import uicolor from arcade.gui.events import ( UIEvent, UIMouseDragEvent, @@ -12,13 +18,15 @@ UIMousePressEvent, UIMouseScrollEvent, UIOnChangeEvent, + UIOnClickEvent, UITextInputEvent, UITextMotionEvent, UITextMotionSelectEvent, ) -from arcade.gui.property import bind +from arcade.gui.property import Property, bind +from arcade.gui.style import UIStyleBase, UIStyledWidget from arcade.gui.surface import Surface -from arcade.gui.widgets import UIWidget +from arcade.gui.widgets import UIInteractiveWidget, UIWidget from arcade.gui.widgets.layout import UIAnchorLayout from arcade.text import FontNameOrNames from arcade.types import LBWH, RGBA255, Color, RGBOrA255 @@ -399,7 +407,27 @@ def ui_label(self) -> UILabel: return self._label -class UIInputText(UIWidget): +@dataclass +class UIInputTextStyle(UIStyleBase): + """Used to style the UITextWidget for different states. Below is its use case. + + .. code:: py + + button = UIInputText(style={"normal": UIInputText.UIStyle(...),}) + + Args: + bg: Background color. + border: Border color. + border_width: Width of the border. + + """ + + bg: RGBA255 | None = None + border: RGBA255 | None = uicolor.WHITE + border_width: int = 2 + + +class UIInputText(UIStyledWidget[UIInputTextStyle], UIInteractiveWidget): """An input field the user can type text into. This is useful in returning @@ -432,9 +460,6 @@ class UIInputText(UIWidget): is the same thing as a :py:class:`~arcade.gui.UITextArea`. caret_color: An RGBA or RGB color for the caret with each channel between 0 and 255, inclusive. - border_color: An RGBA or RGB color for the border with each - channel between 0 and 255, inclusive, can be None to remove border. - border_width: Width of the border in pixels. size_hint: A tuple of floats between 0 and 1 defining the amount of space of the parent should be requested. size_hint_min: Minimum size hint width and height in pixel. @@ -447,13 +472,36 @@ class UIInputText(UIWidget): # position 0. LAYOUT_OFFSET = 1 + # Style + UIStyle = UIInputTextStyle + + DEFAULT_STYLE = { + "normal": UIStyle(), + "hover": UIStyle( + border=uicolor.WHITE_CLOUDS, + ), + "press": UIStyle( + border=uicolor.WHITE_SILVER, + ), + "disabled": UIStyle( + bg=uicolor.WHITE_SILVER, + ), + "invalid": UIStyle( + bg=uicolor.RED_ALIZARIN.replace(a=42), + border=uicolor.RED_ALIZARIN, + ), + } + + # Properties + invalid = Property(False) + def __init__( self, *, x: float = 0, y: float = 0, width: float = 100, - height: float = 23, # required height for font size 12 + border width 1 + height: float = 25, # required height for font size 12 + border width 1 text: str = "", font_name=("Arial",), font_size: float = 12, @@ -465,8 +513,24 @@ def __init__( size_hint=None, size_hint_min=None, size_hint_max=None, + style: Union[dict[str, UIInputTextStyle], None] = None, **kwargs, ): + if border_color != arcade.color.WHITE or border_width != 2: + warnings.warn( + "UIInputText is now a UIStyledWidget. " + "Use the style dict to set the border color and width.", + DeprecationWarning, + stacklevel=1, + ) + + # adjusting style to set border color and width + style = style or UIInputText.DEFAULT_STYLE + style = deepcopy(style) + + style["normal"].border = border_color + style["normal"].border_width = border_width + super().__init__( x=x, y=y, @@ -475,11 +539,10 @@ def __init__( size_hint=size_hint, size_hint_min=size_hint_min, size_hint_max=size_hint_max, + style=style or UIInputText.DEFAULT_STYLE, **kwargs, ) - self.with_border(color=border_color, width=border_width) - self._active = False self._text_color = Color.from_iterable(text_color) @@ -506,6 +569,44 @@ def __init__( self.register_event_type("on_change") + bind(self, "hovered", self._apply_style) + bind(self, "pressed", self._apply_style) + bind(self, "invalid", self._apply_style) + bind(self, "disabled", self._apply_style) + + # initial style application + self._apply_style() + + def _apply_style(self): + style = self.get_current_style() + + self.with_background( + color=Color.from_iterable(style.bg) if style.bg else None, + ) + self.with_border( + color=Color.from_iterable(style.border) if style.border else None, + width=style.border_width, + ) + self.trigger_full_render() + + @override + def get_current_state(self) -> str: + """Get the current state of the slider. + + Returns: + ""normal"", ""hover"", ""press"" or ""disabled"". + """ + if self.disabled: + return "disabled" + elif self.pressed: + return "press" + elif self.hovered: + return "hover" + elif self.invalid: + return "invalid" + else: + return "normal" + def _get_caret_blink_state(self): """Check whether or not the caret is currently blinking or not.""" return self.caret.visible and self._active and self.caret._blink_visible @@ -519,18 +620,14 @@ def on_update(self, dt): self._blink_state = current_state self.trigger_full_render() + def on_click(self, event: UIOnClickEvent): + self.activate() + @override def on_event(self, event: UIEvent) -> bool | None: """Handle events for the text input field. Text input is only active when the user clicks on the input field.""" - # If not active, check to activate, return - if not self._active and isinstance(event, UIMousePressEvent): - if self.rect.point_in_rect(event.pos): - self.activate() - # return unhandled to allow other widgets to deactivate - return EVENT_UNHANDLED - # If active check to deactivate if self._active and isinstance(event, UIMousePressEvent): if self.rect.point_in_rect(event.pos): @@ -571,10 +668,7 @@ def on_event(self, event: UIEvent) -> bool | None: if old_text != self.text: self.dispatch_event("on_change", UIOnChangeEvent(self, old_text, self.text)) - if super().on_event(event): - return EVENT_HANDLED - - return EVENT_UNHANDLED + return super().on_event(event) @property def active(self) -> bool: @@ -585,6 +679,9 @@ def active(self) -> bool: def activate(self): """Programmatically activate the text input field.""" + if self._active: + return + self._active = True self.trigger_full_render() self.caret.on_activate() @@ -592,6 +689,10 @@ def activate(self): def deactivate(self): """Programmatically deactivate the text input field.""" + + if not self._active: + return + self._active = False self.trigger_full_render() self.caret.on_deactivate() diff --git a/tests/unit/gui/test_uiinputtext.py b/tests/unit/gui/test_uiinputtext.py new file mode 100644 index 0000000000..bd321e2ce6 --- /dev/null +++ b/tests/unit/gui/test_uiinputtext.py @@ -0,0 +1,39 @@ +from arcade.gui import UIInputText + + +def test_activates_on_click(ui): + # GIVEN + it = UIInputText(height=30, width=120) + ui.add(it) + + assert it.active is False + + # WHEN + ui.click(*it.center) + + # THEN + assert it.active + + +def test_deactivates_on_click(ui): + # GIVEN + it = UIInputText(height=30, width=120) + ui.add(it) + it.activate() + + # WHEN + ui.click(*it.rect.top_left - (1, 0)) + + # THEN + assert it.active is False + + +def test_changes_state_invalid(ui): + # GIVEN + it = UIInputText(height=30, width=120) + + # WHEN + it.invalid = True + + # THEN + assert it.get_current_state() == "invalid"