8000 Merge pull request #2633 from pythonarcade/gui/styled-uiinputtest · pythonarcade/arcade@a07f60b · GitHub
[go: up one dir, main page]

Skip to content

Commit a07f60b

Browse files
authored
Merge pull request #2633 from pythonarcade/gui/styled-uiinputtest
gui: make UIInputText a styled widget and support invalid state
2 parents 1a608fc + d152d2b commit a07f60b

File tree

4 files changed

+197
-23
lines changed

4 files changed

+197
-23
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
77
## Version 3.1 (unreleased)
88

99
- Drop Python 3.9 support
10+
- Disable shadow window on all platforms to provide a consistent experience
11+
- Performance
12+
- Improved performance of `arcade.SpriteList.remove()` and `arcade.SpriteList.pop()`
13+
- Improved performance of `arcade.hitbox.Hitbox.get_adjusted_points()` ~35%
14+
- Improved performance of `arcade.SpriteList.draw_hit_boxes()` ~20x
15+
- GUI
16+
- `arcade.gui.widgets.text.UIInputText`
17+
- now supports styles for `normal`, `disabled`, `hovered`, `pressed` and `invalid` states
18+
- provides a `invalid` property to indicate if the input is invalid
19+
- Added experimental `arcade.gui.experimental.UIRestrictedInput`
20+
a subclass of `UIInputText` that restricts the input to a specific set of characters
21+
- `arcade.gui.NinePatchTexture` is now lazy and can be created before a window exists allowing creation during imports.
22+
- Improve `arcade.gui.experimental.scroll_area.ScrollBar` behavior to match HTML scrollbars
23+
- Support drawing hitboxes using RBG or RGBA
1024

1125
## Version 3.0.2
1226

arcade/examples/gui/2_widgets.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
UITextureToggle,
3535
UIView,
3636
)
37+
from arcade.gui.experimental import UIPasswordInput
3738

3839
# Load system fonts
3940
arcade.resources.load_kenney_fonts()
@@ -256,14 +257,14 @@ def _show_text_widgets(self):
256257

257258
self._body.clear()
258259

259-
box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left")
260+
box = UIBoxLayout(vertical=True, size_hint=(1, 1), align="left", space_between=10)
260261
self._body.add(box)
261262
box.add(UILabel("Text Widgets", font_name=DEFAULT_FONT, font_size=32))
262263
box.add(UISpace(size_hint=(1, 0.1)))
263264

264265
row_1 = UIBoxLayout(vertical=False, size_hint=(1, 0.1))
265266
box.add(row_1)
266-
row_1.add(UILabel("Name: ", font_name=DEFAULT_FONT, font_size=24))
267+
row_1.add(UILabel("Username: ", font_name=DEFAULT_FONT, font_size=24))
267268
name_input = row_1.add(
268269
UIInputText(
269270
width=400,
@@ -274,12 +275,31 @@ def _show_text_widgets(self):
274275
border_width=2,
275276
)
276277
)
278+
279+
row_2 = UIBoxLayout(vertical=False, size_hint=(1, 0.1))
280+
box.add(row_2)
281+
row_2.add(UILabel("Password: ", font_name=DEFAULT_FONT, font_size=24))
282+
pw_input = row_2.add(
283+
UIPasswordInput(
284+
width=400,
285+
height=40,
286+
font_name=DEFAULT_FONT,
287+
font_size=24,
288+
border_color=arcade.uicolor.GRAY_CONCRETE,
289+
border_width=2,
290+
)
291+
)
292+
293+
@pw_input.event("on_change")
294+
def _(event: UIOnChangeEvent):
295+
event.source.invalid = event.new_value != "arcade"
296+
277297
welcome_label = box.add(
278298
UILabel("Nice to meet you ''", font_name=DEFAULT_FONT, font_size=24)
279299
)
280300

281301
@name_input.event("on_change")
282-
def on_text_change(event: UIOnChangeEvent):
302+
def _(event: UIOnChangeEvent):
283303
welcome_label.text = f"Nice to meet you `{event.new_value}`"
284304

285305
box.add(UISpace(size_hint=(1, 0.3))) # Fill some of the left space

arcade/gui/widgets/text.py

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
1+
import warnings
2+
from copy import deepcopy
3+
from dataclasses import dataclass
4+
from typing import Union
5+
16
import pyglet
27
from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED
38
from pyglet.text.caret import Caret
49
from pyglet.text.document import AbstractDocument
510
from typing_extensions import Literal, override
611

712
import arcade
13+
from arcade import uicolor
814
from arcade.gui.events import (
915
UIEvent,
1016
UIMouseDragEvent,
1117
UIMouseEvent,
1218
UIMousePressEvent,
1319
UIMouseScrollEvent,
1420
UIOnChangeEvent,
21+
UIOnClickEvent,
1522
UITextInputEvent,
1623
UITextMotionEvent,
1724
UITextMotionSelectEvent,
1825
)
19-
from arcade.gui.property import bind
26+
from arcade.gui.property import Property, bind
27+
from arcade.gui.style import UIStyleBase, UIStyledWidget
2028
from arcade.gui.surface import Surface
21-
from arcade.gui.widgets import UIWidget
29+
from arcade.gui.widgets import UIInteractiveWidget, UIWidget
2230
from arcade.gui.widgets.layout import UIAnchorLayout
2331
from arcade.text import FontNameOrNames
2432
from arcade.types import LBWH, RGBA255, Color, RGBOrA255
@@ -399,7 +407,27 @@ def ui_label(self) -> UILabel:
399407
return self._label
400408

401409

402-
class UIInputText(UIWidget):
410+
@dataclass
411+
class UIInputTextStyle(UIStyleBase):
412+
"""Used to style the UITextWidget for different states. Below is its use case.
413+
414+
.. code:: py
415+
416+
button = UIInputText(style={"normal": UIInputText.UIStyle(...),})
417+
418+
Args:
419+
bg: Background color.
420+
border: Border color.
421+
border_width: Width of the border.
422+
423+
"""
424+
425+
bg: RGBA255 | None = None
426+
border: RGBA255 | None = uicolor.WHITE
427+
border_width: int = 2
428+
429+
430+
class UIInputText(UIStyledWidget[UIInputTextStyle], UIInteractiveWidget):
403431
"""An input field the user can type text into.
404432
405433
This is useful in returning
@@ -432,9 +460,6 @@ class UIInputText(UIWidget):
432460
is the same thing as a :py:class:`~arcade.gui.UITextArea`.
433461
caret_color: An RGBA or RGB color for the caret with each
434462
channel between 0 and 255, inclusive.
435-
border_color: An RGBA or RGB color for the border with each
436-
channel between 0 and 255, inclusive, can be None to remove border.
437-
border_width: Width of the border in pixels.
438463
size_hint: A tuple of floats between 0 and 1 defining the amount
439464
of space of the parent should be requested.
440465
size_hint_min: Minimum size hint width and height in pixel.
@@ -447,13 +472,36 @@ class UIInputText(UIWidget):
447472
# position 0.
448473
LAYOUT_OFFSET = 1
449474

475+
# Style
476+
UIStyle = UIInputTextStyle
477+
478+
DEFAULT_STYLE = {
479+
"normal": UIStyle(),
480+
"hover": UIStyle(
481+
border=uicolor.WHITE_CLOUDS,
482+
),
483+
"press": UIStyle(
484+
border=uicolor.WHITE_SILVER,
485+
),
486+
"disabled": UIStyle(
487+
bg=uicolor.WHITE_SILVER,
488+
),
489+
"invalid": UIStyle(
490+
bg=uicolor.RED_ALIZARIN.replace(a=42),
491+
border=uicolor.RED_ALIZARIN,
492+
),
493+
}
494+
495+
# Properties
496+
invalid = Property(False)
497+
450498
def __init__(
451499
self,
452500
*,
453501
x: float = 0,
454502
y: float = 0,
455503
width: float = 100,
456-
height: float = 23, # required height for font size 12 + border width 1
504+
height: float = 25, # required height for font size 12 + border width 1
457505
text: str = "",
458506
font_name=("Arial",),
459507
font_size: float = 12,
@@ -465,8 +513,24 @@ def __init__(
465513
size_hint 10000 =None,
466514
size_hint_min=None,
467515
size_hint_max=None,
516+
style: Union[dict[str, UIInputTextStyle], None] = None,
468517
**kwargs,
469518
):
519+
if border_color != arcade.color.WHITE or border_width != 2:
520+
warnings.warn(
521+
"UIInputText is now a UIStyledWidget. "
522+
"Use the style dict to set the border color and width.",
523+
DeprecationWarning,
524+
stacklevel=1,
525+
)
526+
527+
# adjusting style to set border color and width
528+
style = style or UIInputText.DEFAULT_STYLE
529+
style = deepcopy(style)
530+
531+
style["normal"].border = border_color
532+
style["normal"].border_width = border_width
533+
470534
super().__init__(
471535
x=x,
472536
y=y,
@@ -475,11 +539,10 @@ def __init__(
475539
size_hint=size_hint,
476540
size_hint_min=size_hint_min,
477541
size_hint_max=size_hint_max,
542+
style=style or UIInputText.DEFAULT_STYLE,
478543
**kwargs,
479544
)
480545

481-
self.with_border(color=border_color, width=border_width)
482-
483546
self._active = False
484547
self._text_color = Color.from_iterable(text_color)
485548

@@ -506,6 +569,44 @@ def __init__(
506569

507570
self.register_event_type("on_change")
508571

572+
bind(self, "hovered", self._apply_style)
573+
bind(self, "pressed", self._apply_style)
574+
bind(self, "invalid", self._apply_style)
575+
bind(self, "disabled", self._apply_style)
576+
577+
# initial style application
578+
self._apply_style()
579+
580+
def _apply_style(self):
581+
style = self.get_current_style()
582+
583+
self.with_background(
584+
color=Color.from_iterable(style.bg) if style.bg else None,
585+
)
586+
self.with_border(
587+
color=Color.from_iterable(style.border) if style.border else None,
588+
width=style.border_width,
589+
)
590+
self.trigger_full_render()
591+
592+
@override
593+
def get_current_state(self) -> str:
594+
"""Get the current state of the slider.
595+
596+
Returns:
597+
""normal"", ""hover"", ""press"" or ""disabled"".
598+
"""
599+
if self.disabled:
600+
return "disabled"
601+
elif self.pressed:
602+
return "press"
603+
elif self.hovered:
604+
return "hover"
605+
elif self.invalid:
606+
return "invalid"
607+
else:
608+
return "normal"
609+
509610
def _get_caret_blink_state(self):
510611
"""Check whether or not the caret is currently blinking or not."""
511612
return self.caret.visible and self._active and self.caret._blink_visible
@@ -519,18 +620,14 @@ def on_update(self, dt):
519620
self._blink_state = current_state
520621
self.trigger_full_render()
521622

623+
def on_click(self, event: UIOnClickEvent):
624+
self.activate()
625+
522626
@override
523627
def on_event(self, event: UIEvent) -> bool | None:
524628
"""Handle events for the text input field.
525629
526630
Text input is only active when the user clicks on the input field."""
527-
# If not active, check to activate, return
528-
if not self._active and isinstance(event, UIMousePressEvent):
529-
if self.rect.point_in_rect(event.pos):
530-
self.activate()
531-
# return unhandled to allow other widgets to deactivate
532-
return EVENT_UNHANDLED
533-
534631
# If active check to deactivate
535632
if self._active and isinstance(event, UIMousePressEvent):
536633
if self.rect.point_in_rect(event.pos):
@@ -571,10 +668,7 @@ def on_event(self, event: UIEvent) -> bool | None:
571668
if old_text != self.text:
572669
self.dispatch_event("on_change", UIOnChangeEvent(self, old_text, self.text))
573670

574-
if super().on_event(event):
575-
return EVENT_HANDLED
576-
577-
return EVENT_UNHANDLED
671+
return super().on_event(event)
578672

579673
@property
580674
def active(self) -> bool:
@@ -585,13 +679,20 @@ def active(self) -> bool:
585679

586680
def activate(self):
587681
"""Programmatically activate the text input field."""
682+
if self._active:
683+
return
684+
588685
self._active = True
589686
self.trigger_full_render()
590687
self.caret.on_activate()
591688
self.caret.position = len(self.doc.text)
592689

593690
def deactivate(self):
594691
"""Programmatically deactivate the text input field."""
692+
693+
if not self._active:
694+
return
695+
595696
self._active = False
596697
self.trigger_full_render()
597698
self.caret.on_deactivate()

tests/unit/gui/test_uiinputtext.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from arcade.gui import UIInputText
2+
3+
4+
def test_activates_on_click(ui):
5+
# GIVEN
6+
it = UIInputText(height=30, width=120)
7+
ui.add(it)
8+
9+
assert it.active is False
10+
11+
# WHEN
12+
ui.click(*it.center)
13+
14+
# THEN
15+
assert it.active
16+
17+
18+
def test_deactivates_on_click(ui):
19+
# GIVEN
20+
it = UIInputText(height=30, width=120)
21+
ui.add(it)
22+
it.activate()
23+
24+
# WHEN
25+
ui.click(*it.rect.top_left - (1, 0))
26+
27+
# THEN
28+
assert it.active is False
29+
30+
31+
def test_changes_state_invalid(ui):
32+
# GIVEN
33+
it = UIInputText(height=30, width=120)
34+
35+
# WHEN
36+
it.invalid = True
37+
38+
# THEN
39+
assert it.get_current_state() == "invalid"

0 commit comments

Comments
 (0)
0