Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 23 additions & 3 deletions arcade/examples/gui/2_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
UITextureToggle,
UIView,
)
from arcade.gui.experimental import UIPasswordInput

# Load system fonts
arcade.resources.load_kenney_fonts()
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
141 changes: 121 additions & 20 deletions arcade/gui/widgets/text.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
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
from pyglet.text.document import AbstractDocument
from typing_extensions import Literal, override

import arcade
from arcade import uicolor
from arcade.gui.events import (
UIEvent,
UIMouseDragEvent,
UIMouseEvent,
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -585,13 +679,20 @@ 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()
self.caret.position = len(self.doc.text)

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()
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/gui/test_uiinputtext.py
Original file line number Diff line number Diff line change
@@ -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"
Loading