Skip to content

Commit 61248f2

Browse files
authored
Gui/controller focus improvements (#2757)
* controller window skip initial events * gui: move focus interaction into widget code * gui: add escape handler to close example * gui: make use of focus group to switch between input fields * gui: remove debug prints * gui: fix flickering of UIInputText when focused and space pressed * gui: update UIFocusGroup docs
1 parent b084ae3 commit 61248f2

File tree

9 files changed

+133
-120
lines changed

9 files changed

+133
-120
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
88
- GUI
99
- Fix a bug, where the caret of UIInputText was misplaced after resizing the widget
1010
- Use incremental layout for UIScrollArea to improve performance of changing text
11+
- Refactored and improved focus handling
1112

1213
## 3.3.2
1314

arcade/examples/gui/exp_controller_support.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
python -m arcade.examples.gui.exp_controller_support
1010
"""
1111

12-
1312
import arcade
1413
from arcade import Texture
1514
from arcade.experimental.controller_window import ControllerWindow, ControllerView
@@ -143,6 +142,7 @@ def __init__(self):
143142
root.add(UIFlatButton(text="Close")).on_click = self.close
144143

145144
self.detect_focusable_widgets()
145+
self.set_focus()
146146

147147
def on_event(self, event):
148148
if super().on_event(event):
@@ -190,6 +190,13 @@ def on_button_click(self, event: UIOnClickEvent):
190190
print("Button clicked")
191191
self.root.add(ControllerModal())
192192

193+
def on_key_press(self, symbol: int, modifiers: int) -> bool | None:
194+
# make the example close with the escape key
195+
if symbol == arcade.key.ESCAPE:
196+
self.window.close()
197+
return True
198+
return super().on_key_press(symbol, modifiers)
199+
193200

194201
if __name__ == "__main__":
195202
window = ControllerWindow(title="Controller UI Example")

arcade/examples/gui/exp_controller_support_grid.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
python -m arcade.examples.gui.exp_controller_support_grid
1010
"""
1111

12-
1312
import arcade
1413
from arcade.examples.gui.exp_controller_support import ControllerIndicator
1514
from arcade.experimental.controller_window import ControllerView, ControllerWindow
@@ -88,6 +87,13 @@ def __init__(self):
8887

8988
self.root.detect_focusable_widgets()
9089

90+
def on_key_press(self, symbol: int, modifiers: int) -> bool | None:
91+
# make the example close with the escape key
92+
if symbol == arcade.key.ESCAPE:
93+
self.window.close()
94+
return True
95+
return super().on_key_press(symbol, modifiers)
96+
9197

9298
if __name__ == "__main__":
9399
window = ControllerWindow(title="Controller UI Example")

arcade/examples/gui/exp_hidden_password.py

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
"""
1414

1515
import arcade
16+
from arcade.experimental.controller_window import ControllerWindow
1617
from arcade.gui import UIInputText, UIOnClickEvent, UIView
18+
from arcade.gui.experimental.focus import UIFocusGroup
1719
from arcade.gui.experimental.password_input import UIPasswordInput
1820
from arcade.gui.widgets.buttons import UIFlatButton
19-
from arcade.gui.widgets.layout import UIGridLayout, UIAnchorLayout
21+
from arcade.gui.widgets.layout import UIGridLayout
2022
from arcade.gui.widgets.text import UILabel
2123
from arcade import resources
2224

@@ -80,32 +82,25 @@ def __init__(self):
8082
column_span=2,
8183
)
8284

83-
anchor = UIAnchorLayout() # to center grid on screen
85+
anchor = UIFocusGroup() # to center grid on screen
8486
anchor.add(grid)
8587

8688
self.add_widget(anchor)
8789

8890
# activate username input field
89-
self.username_input.activate()
91+
anchor.detect_focusable_widgets()
92+
anchor.set_focus()
9093

9194
def on_key_press(self, symbol: int, modifiers: int) -> bool | None:
95+
# make the example close with the escape key
96+
if symbol == arcade.key.ESCAPE:
97+
self.window.close()
98+
return True
99+
92100
# if username field active, switch fields with enter
93-
if self.username_input.active:
94-
if symbol == arcade.key.TAB:
95-
self.username_input.deactivate()
96-
self.password_input.activate()
97-
return True
98-
elif symbol == arcade.key.ENTER:
101+
elif self.username_input.active or self.password_input.active:
102+
if symbol == arcade.key.ENTER:
99103
self.username_input.deactivate()
100-
self.on_login(None)
101-
return True
102-
# if password field active, login with enter
103-
elif self.password_input.active:
104-
if symbol == arcade.key.TAB:
105-
self.username_input.activate()
106-
self.password_input.deactivate()
107-
return True
108-
elif symbol == arcade.key.ENTER:
109104
self.password_input.deactivate()
110105
self.on_login(None)
111106
return True
@@ -118,7 +113,7 @@ def on_login(self, event: UIOnClickEvent | None):
118113

119114

120115
def main():
121-
window = arcade.Window(title="GUI Example: Hidden Password")
116+
window = ControllerWindow(title="GUI Example: Hidden Password")
122117
window.show_view(MyView())
123118
window.run()
124119

arcade/experimental/controller_window.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,11 @@ def __init__(self, window: arcade.Window):
2929
self.on_connect(controller)
3030

3131
def on_connect(self, controller: Controller):
32-
controller.push_handlers(self)
33-
3432
try:
3533
controller.open()
3634
except Exception as e:
3735
warnings.warn(f"Failed to open controller {controller}: {e}")
36+
controller.push_handlers(self)
3837

3938
self.window.dispatch_event("on_connect", controller)
4039

arcade/gui/experimental/focus.py

Lines changed: 13 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,16 @@
55
from pyglet.math import Vec2
66

77
import arcade
8-
from arcade import MOUSE_BUTTON_LEFT
98
from arcade.gui.events import (
10-
UIControllerButtonPressEvent,
11-
UIControllerButtonReleaseEvent,
129
UIControllerDpadEvent,
1310
UIControllerEvent,
1411
UIEvent,
1512
UIKeyPressEvent,
16-
UIKeyReleaseEvent,
17-
UIMousePressEvent,
18-
UIMouseReleaseEvent,
1913
)
2014
from arcade.gui.property import ListProperty, Property, bind
2115
from arcade.gui.surface import Surface
22-
from arcade.gui.widgets import FocusMode, UIInteractiveWidget, UIWidget
16+
from arcade.gui.widgets import FocusMode, UIWidget
2317
from arcade.gui.widgets.layout import UIAnchorLayout
24-
from arcade.gui.widgets.slider import UIBaseSlider
2518

2619

2720
class UIFocusable(UIWidget):
@@ -100,54 +93,22 @@ def on_event(self, event: UIEvent) -> bool | None:
10093

10194
return EVENT_HANDLED
10295

103-
elif event.symbol == arcade.key.SPACE:
104-
self._start_interaction()
96+
elif isinstance(event, UIControllerDpadEvent):
97+
# switch focus
98+
if event.vector.x == 1:
99+
self.focus_right()
105100
return EVENT_HANDLED
106101

107-
elif isinstance(event, UIKeyReleaseEvent):
108-
if event.symbol == arcade.key.SPACE:
109-
self._end_interaction()
102+
elif event.vector.y == 1:
103+
self.focus_up()
110104
return EVENT_HANDLED
111105

112-
elif isinstance(event, UIControllerDpadEvent):
113-
if self._interacting:
114-
# TODO this should be handled in the slider!
115-
# pass dpad events to the interacting widget
116-
if event.vector.x == 1 and isinstance(self._interacting, UIBaseSlider):
117-
self._interacting.norm_value += 0.1
118-
return EVENT_HANDLED
119-
120-
elif event.vector.x == -1 and isinstance(self._interacting, UIBaseSlider):
121-
self._interacting.norm_value -= 0.1
122-
return EVENT_HANDLED
123-
106+
elif event.vector.x == -1:
107+
self.focus_left()
124108
return EVENT_HANDLED
125109

126-
else:
127-
# switch focus
128-
if event.vector.x == 1:
129-
self.focus_right()
130-
return EVENT_HANDLED
131-
132-
elif event.vector.y == 1:
133-
self.focus_up()
134-
return EVENT_HANDLED
135-
136-
elif event.vector.x == -1:
137-
self.focus_left()
138-
return EVENT_HANDLED
139-
140-
elif event.vector.y == -1:
141-
self.focus_down()
142-
return EVENT_HANDLED
143-
144-
elif isinstance(event, UIControllerButtonPressEvent):
145-
if event.button == "a":
146-
self._start_interaction()
147-
return EVENT_HANDLED
148-
elif isinstance(event, UIControllerButtonReleaseEvent):
149-
if event.button == "a":
150-
self._end_interaction()
110+
elif event.vector.y == -1:
111+
self.focus_down()
151112
return EVENT_HANDLED
152113

153114
return EVENT_UNHANDLED
@@ -278,48 +239,6 @@ def focus_previous(self):
278239
# automatically wrap around via index -1
279240
self.set_focus(self._focusable_widgets[focused_index])
280241

281-
def _start_interaction(self):
282-
# TODO this should be handled in the widget
283-
284-
widget = self.focused_widget
285-
286-
if isinstance(widget, UIInteractiveWidget):
287-
widget.dispatch_ui_event(
288-
UIMousePressEvent(
289-
source=self,
290-
x=int(widget.rect.center_x),
291-
y=int(widget.rect.center_y),
292-
button=MOUSE_BUTTON_LEFT,
293-
modifiers=0,
294-
)
295-
)
296-
self._interacting = widget
297-
else:
298-
print("Cannot interact widget")
299-
300-
def _end_interaction(self):
301-
widget = self.focused_widget
302-
303-
if isinstance(widget, UIInteractiveWidget):
304-
if isinstance(self._interacting, UIBaseSlider):
305-
# if slider, release outside the slider
306-
x = self._interacting.rect.left - 1
307-
y = self._interacting.rect.bottom - 1
308-
else:
309-
x = widget.rect.center_x
310-
y = widget.rect.center_y
311-
312-
self._interacting = None
313-
widget.dispatch_ui_event(
314-
UIMouseReleaseEvent(
315-
source=self,
316-
x=int(x),
317-
y=int(y),
318-
button=MOUSE_BUTTON_LEFT,
319-
modifiers=0,
320-
)
321-
)
322-
323242
def _do_render(self, surface: Surface, force=False) -> bool:
324243
rendered = super()._do_render(surface, force)
325244

@@ -373,4 +292,5 @@ def is_focusable(widget):
373292

374293

375294
class UIFocusGroup(UIFocusMixin, UIAnchorLayout):
376-
pass
295+
"""This will be removed in the future.
296+
UIFocusMixin is planned to be integrated into UILayout."""

arcade/gui/widgets/__init__.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
UIMouseReleaseEvent,
2323
UIOnClickEvent,
2424
UIOnUpdateEvent,
25+
UIControllerButtonPressEvent,
26+
UIControllerButtonReleaseEvent,
27+
UIKeyPressEvent,
28+
UIKeyReleaseEvent,
2529
)
2630
from arcade.gui.nine_patch import NinePatchTexture
2731
from arcade.gui.property import ListProperty, Property, bind
@@ -745,6 +749,13 @@ def __init__(
745749
bind(self, "pressed", UIInteractiveWidget.trigger_render)
746750
bind(self, "hovered", UIInteractiveWidget.trigger_render)
747751
bind(self, "disabled", UIInteractiveWidget.trigger_render)
752+
bind(self, "focused", UIInteractiveWidget._on_focus_change)
753+
754+
def _on_focus_change(self):
755+
"""If focus lost, release active state"""
756+
if self.pressed and not self.focused:
757+
self.pressed = False
758+
self._release_active()
748759

749760
def on_event(self, event: UIEvent) -> bool | None:
750761
"""Handles mouse events and triggers on_click event if the widget is clicked.
@@ -754,6 +765,7 @@ def on_event(self, event: UIEvent) -> bool | None:
754765
if super().on_event(event):
755766
return EVENT_HANDLED
756767

768+
# mouse event handling
757769
if isinstance(event, UIMouseMovementEvent):
758770
self.hovered = self.rect.point_in_rect(event.pos)
759771

@@ -788,6 +800,43 @@ def on_event(self, event: UIEvent) -> bool | None:
788800
)
789801
return EVENT_HANDLED # TODO should we return the result from on_click?
790802

803+
# focus related events
804+
if self.focused:
805+
if isinstance(event, UIKeyPressEvent) and event.symbol == arcade.key.SPACE:
806+
self.pressed = True
807+
self._grap_active() # make this the active widget
808+
return EVENT_HANDLED
809+
810+
if isinstance(event, UIControllerButtonPressEvent) and event.button in ("a",):
811+
self.pressed = True
812+
self._grap_active() # make this the active widget
813+
return EVENT_HANDLED
814+
815+
if self.pressed:
816+
keyboard_interaction = (
817+
isinstance(event, UIKeyReleaseEvent) and event.symbol == arcade.key.SPACE
818+
)
819+
controller_interaction = isinstance(
820+
event, UIControllerButtonReleaseEvent
821+
) and event.button in ("a",)
822+
823+
if keyboard_interaction or controller_interaction:
824+
self.pressed = False
825+
if not self.disabled:
826+
# Dispatch new on_click event, source is this widget itself
827+
self._grap_active()
828+
self.dispatch_event(
829+
"on_click",
830+
UIOnClickEvent( # simulate mouse click
831+
source=self,
832+
x=int(self.center_x),
833+
y=int(self.center_y),
834+
button=self.interaction_buttons[0],
835+
modifiers=0,
836+
),
837+
)
838+
return EVENT_HANDLED # TODO should we return the result from on_click?
839+
791840
return EVENT_UNHANDLED
792841

793842
def on_click(self, event: UIOnClickEvent):

0 commit comments

Comments
 (0)