Skip to content

Commit 2fbb0f1

Browse files
authored
Merge pull request #2740 from pythonarcade/gui/fix-double-caret
Gui/fix double caret
2 parents 677015f + 4ca85d9 commit 2fbb0f1

File tree

6 files changed

+100
-20
lines changed

6 files changed

+100
-20
lines changed

CHANGELOG.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
1212
- Added `Text.visible` (bool) property to control the visibility of text objects.
1313
- Fixed an issue causing points and lines to draw random primitives when
1414
passing in an empty list.
15+
- GUI
16+
- Fix caret did not deactivate because of consumed mouse events. [2725](https://github.com/pythonarcade/arcade/issues/2725)
17+
- Property listener can now receive:
18+
- no args
19+
- instance
20+
- instance, value
21+
- instance, value, old value
22+
> Listener accepting `*args` receive `instance, value` like in previous versions.
1523
1624
## 3.3.0
1725

@@ -36,12 +44,6 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
3644
- Added property setters for `center_x` and `center_y`
3745
- Added property setters for `left`, `right`, `top`, and `bottom`
3846
- Users can now set widget position and size more intuitively without needing to access the `rect` property
39-
- Property listener can now receive:
40-
- no args
41-
- instance
42-
- instance, value
43-
- instance, value, old value
44-
> Listener accepting `*args` receive `instance, value` like in previous versions.
4547

4648
- Rendering:
4749
- The `arcade.gl` package was restructured to be more modular in preparation for

arcade/gui/property.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,10 @@ class MyObject:
230230
"""
231231
t = type(instance)
232232
prop = getattr(t, property)
233-
if isinstance(prop, Property):
234-
prop.bind(instance, callback)
233+
if not isinstance(prop, Property):
234+
raise ValueError(f"{t.__name__}.{property} is not an arcade.gui.Property")
235+
236+
prop.bind(instance, callback)
235237

236238

237239
def unbind(instance, property: str, callback):

arcade/gui/ui_manager.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ def on_draw():
9999
"""Experimental feature to pixelate the UI, all textures will be rendered pixelated,
100100
which will mostly influence scaled background images.
101101
This property has to be set right after the UIManager is created."""
102+
_active_widget: UIWidget | None = None
103+
"""The currently active widget. Any widget, which consumes mouse press or release events
104+
should set itself as active widget.
105+
UIManager ensures that only one widget can be active at a time,
106+
which can be used by widgets like text fields to detect when they are disabled,
107+
without relying on unconsumed mouse press or release events."""
102108

103109
DEFAULT_LAYER = 0
104110
OVERLAY_LAYER = 10
@@ -518,6 +524,19 @@ def rect(self) -> Rect:
518524
"""The rect of the UIManager, which is the window size."""
519525
return LBWH(0, 0, *self.window.get_size())
520526

527+
def _set_active_widget(self, widget: UIWidget | None):
528+
if self._active_widget == widget:
529+
return
530+
531+
if self._active_widget:
532+
print(f"Deactivating widget {self._active_widget.__class__.__name__}")
533+
self._active_widget._active = False
534+
535+
self._active_widget = widget
536+
if self._active_widget:
537+
print(f"Activating widget {self._active_widget.__class__.__name__}")
538+
self._active_widget._active = True
539+
521540
def debug(self):
522541
"""Walks through all widgets of a UIManager and prints out layout information."""
523542
for index, layer in self.children.items():

arcade/gui/widgets/__init__.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ class UIWidget(EventDispatcher, ABC):
9494
This is not part of the public API and subject to change.
9595
UILabel have a strong background if set.
9696
"""
97+
_active = Property[bool](False)
98+
"""If True, the widget is active"""
9799

98100
def __init__(
99101
self,
@@ -167,6 +169,23 @@ def add(self, child: W, **kwargs) -> W:
167169

168170
return child
169171

172+
# TODO "focus" would be more intuative but clashes with the UIFocusGroups :/
173+
# maybe the two systems should be merged?
174+
def _grap_active(self):
175+
"""Sets itself as the single active widget in the UIManager."""
176+
ui_manager: UIManager | None = self.get_ui_manager()
177+
if ui_manager:
178+
ui_manager._set_active_widget(self)
179+
180+
def _release_active(self):
181+
"""Make this widget inactive in the UIManager."""
182+
if not self._active:
183+
return
184+
185+
ui_manager: UIManager | None = self.get_ui_manager()
186+
if ui_manager and ui_manager._active_widget is self:
187+
ui_manager._set_active_widget(None)
188+
170189
def remove(self, child: UIWidget) -> dict | None:
171190
"""Removes a child from the UIManager which was directly added to it.
172191
This will not remove widgets which are added to a child of UIManager.
@@ -694,6 +713,7 @@ def on_event(self, event: UIEvent) -> bool | None:
694713
and event.button in self.interaction_buttons
695714
):
696715
self.pressed = True
716+
self._grap_active() # make this the active widget
697717
return EVENT_HANDLED
698718

699719
if (
@@ -705,6 +725,7 @@ def on_event(self, event: UIEvent) -> bool | None:
705725
if self.rect.point_in_rect(event.pos):
706726
if not self.disabled:
707727
# Dispatch new on_click event, source is this widget itself
728+
self._grap_active() # make this the active widget
708729
self.dispatch_event(
709730
"on_click",
710731
UIOnClickEvent(
@@ -715,7 +736,7 @@ def on_event(self, event: UIEvent) -> bool | None:
715736
modifiers=event.modifiers,
716737
),
717738
)
718-
return EVENT_HANDLED
739+
return EVENT_HANDLED # TODO should we return the result from on_click?
719740

720741
return EVENT_UNHANDLED
721742

arcade/gui/widgets/text.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
UIMouseDragEvent,
1717
UIMouseEvent,
1818
UIMousePressEvent,
19+
UIMouseReleaseEvent,
1920
UIMouseScrollEvent,
2021
UIOnChangeEvent,
2122
UIOnClickEvent,
@@ -544,7 +545,6 @@ def __init__(
544545
**kwargs,
545546
)
546547

547-
self._active = False
548548
self._text_color = Color.from_iterable(text_color)
549549

550550
self.doc: AbstractDocument = pyglet.text.decode_text(text)
@@ -574,10 +574,17 @@ def __init__(
574574
bind(self, "pressed", self._apply_style)
575575
bind(self, "invalid", self._apply_style)
576576
bind(self, "disabled", self._apply_style)
577+
bind(self, "_active", self._on_active_changed)
577578

578579
# initial style application
579580
self._apply_style()
580581

582+
def _on_active_changed(self):
583+
"""Handle the active state change of the input
584+
text field to care about loosing active state."""
585+
if not self._active:
586+
self.deactivate()
587+
581588
def _apply_style(self):
582589
style = self.get_current_style()
583590

@@ -630,12 +637,25 @@ def on_event(self, event: UIEvent) -> bool | None:
630637
631638
Text input is only active when the user clicks on the input field."""
632639
# If active check to deactivate
633-
if self._active and isinstance(event, UIMousePressEvent):
634-
if self.rect.point_in_rect(event.pos):
635-
x = int(event.x - self.left - self.LAYOUT_OFFSET)
636-
y = int(event.y - self.bottom)
637-
self.caret.on_mouse_press(x, y, event.button, event.modifiers)
638-
else:
640+
if self._active and isinstance(event, UIMouseEvent):
641+
event_in_rect = self.rect.point_in_rect(event.pos)
642+
643+
# mouse press
644+
if isinstance(event, UIMousePressEvent):
645+
# inside the input field
646+
if event_in_rect:
647+
x = int(event.x - self.left - self.LAYOUT_OFFSET)
648+
y = int(event.y - self.bottom)
649+
self.caret.on_mouse_press(x, y, event.button, event.modifiers)
650+
else:
651+
# outside the input field
652+
self.deactivate()
653+
# return unhandled to allow other widgets to activate
654+
return EVENT_UNHANDLED
655+
656+
# mouse release outside the input field,
657+
# which could be a click on another widget, which handles the press event
658+
if isinstance(event, UIMouseReleaseEvent) and not event_in_rect:
639659
self.deactivate()
640660
# return unhandled to allow other widgets to activate
641661
return EVENT_UNHANDLED
@@ -683,18 +703,20 @@ def activate(self):
683703
if self._active:
684704
return
685705

686-
self._active = True
706+
self._grap_active() # will set _active to True
687707
self.trigger_full_render()
688708
self.caret.on_activate()
689709
self.caret.position = len(self.doc.text)
690710

691711
def deactivate(self):
692712
"""Programmatically deactivate the text input field."""
693713

694-
if not self._active:
695-
return
714+
if self._active:
715+
print("Release active text input field")
716+
self._release_active() # will set _active to False
717+
else:
718+
print("Text input field is not active, cannot deactivate")
696719

697-
self._active = False
698720
self.trigger_full_render()
699721
self.caret.on_deactivate()
700722

tests/unit/gui/test_property.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import gc
22

3+
import pytest
4+
35
from arcade.gui.property import Property, bind, unbind
46

57

@@ -241,3 +243,15 @@ def callback(*args, **kwargs):
241243
del callback
242244

243245
assert len(MyObject.name.obs[obj]._listeners) == 1
246+
247+
248+
def test_bind_raise_if_attr_not_a_ui_property():
249+
class BadObject:
250+
@property
251+
def name(self):
252+
return
253+
254+
obj = BadObject()
255+
256+
with pytest.raises(ValueError):
257+
bind(obj, "name", lambda *args: None)

0 commit comments

Comments
 (0)