Skip to content

Commit 72373c1

Browse files
committed
gui:controller support draft
1 parent 8be3699 commit 72373c1

File tree

2 files changed

+396
-0
lines changed

2 files changed

+396
-0
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from typing import Optional
2+
3+
import arcade
4+
from arcade import Texture
5+
from arcade.gui import (
6+
UIAnchorLayout,
7+
UIBoxLayout,
8+
UIEvent,
9+
UIFlatButton,
10+
UIImage,
11+
UIMouseFilterMixin,
12+
UIOnClickEvent,
13+
UIView,
14+
)
15+
from arcade.gui.experimental.controller import (
16+
UIControllerBridge,
17+
UIControllerButtonPressEvent,
18+
UIControllerDpadEvent,
19+
UIFocusGroup,
20+
)
21+
22+
23+
class ControllerIndicator(UIAnchorLayout):
24+
BLANK_TEX = Texture.create_empty("empty", (40, 40), arcade.color.TRANSPARENT_BLACK)
25+
26+
def __init__(self):
27+
super().__init__()
28+
29+
self._indicator = self.add(UIImage(texture=self.BLANK_TEX), anchor_y="bottom", align_y=10)
30+
31+
def on_event(self, event: UIEvent) -> Optional[bool]:
32+
if isinstance(event, UIControllerButtonPressEvent):
33+
self._indicator.texture = arcade.load_texture(
34+
f":resources:onscreen_controls/flat_dark/{event.button}.png"
35+
)
36+
arcade.unschedule(self.reset)
37+
arcade.schedule_once(self.reset, 0.5)
38+
elif isinstance(event, UIControllerDpadEvent):
39+
tex_map = {
40+
(1, 0): "right",
41+
(-1, 0): "left",
42+
(0, 1): "up",
43+
(0, -1): "down",
44+
}
45+
46+
if event.vector in tex_map:
47+
self._indicator.texture = arcade.load_texture(
48+
f":resources:onscreen_controls/flat_dark/{tex_map[event.vector]}.png"
49+
)
50+
arcade.unschedule(self.reset)
51+
arcade.schedule_once(self.reset, 0.5)
52+
53+
return super().on_event(event)
54+
55+
def reset(self, *_):
56+
print("Reset")
57+
self._indicator.texture = self.BLANK_TEX
58+
self.trigger_full_render()
59+
60+
61+
class ControllerModal(UIMouseFilterMixin, UIFocusGroup):
62+
def __init__(self):
63+
super().__init__(size_hint=(0.8, 0.8))
64+
self.with_background(color=arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE)
65+
66+
root = self.add(UIBoxLayout(space_between=10))
67+
68+
root.add(UIFlatButton(text="Modal Button 1"))
69+
root.add(UIFlatButton(text="Modal Button 2"))
70+
root.add(UIFlatButton(text="Modal Button 3"))
71+
root.add(UIFlatButton(text="Close")).on_click = self.close
72+
73+
self.detect_focusable_widgets()
74+
75+
def on_event(self, event):
76+
if super().on_event(event):
77+
return True
78+
79+
if isinstance(event, UIControllerButtonPressEvent):
80+
if event.button == "b":
81+
self.close(None)
82+
return True
83+
84+
return False
85+
86+
def close(self, event):
87+
print("Close")
88+
# self.trigger_full_render()
89+
self.trigger_full_render()
90+
self.parent.remove(self)
91+
92+
93+
class MyView(UIView):
94+
def __init__(self):
95+
super().__init__()
96+
arcade.set_background_color(arcade.color.AMAZON)
97+
98+
self.controller_bridge = UIControllerBridge(self.ui)
99+
100+
self.root = self.add_widget(ControllerIndicator())
101+
self.root = self.root.add(UIFocusGroup())
102+
box = self.root.add(UIBoxLayout(space_between=10), anchor_x="left")
103+
104+
box.add(UIFlatButton(text="Button 1")).on_click = self.on_button_click
105+
box.add(UIFlatButton(text="Button 2")).on_click = self.on_button_click
106+
box.add(UIFlatButton(text="Button 3")).on_click = self.on_button_click
107+
108+
self.root.detect_focusable_widgets()
109+
110+
def on_button_click(self, event: UIOnClickEvent):
111+
print("Button clicked")
112+
self.root.add(ControllerModal())
113+
114+
115+
if __name__ == "__main__":
116+
window = arcade.Window(title="Controller UI Example")
117+
window.show_view(MyView())
118+
arcade.run()

arcade/gui/experimental/controller.py

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
from dataclasses import dataclass
2+
from typing import Optional
3+
4+
from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED
5+
from pyglet.input import Controller
6+
from pyglet.math import Vec2
7+
8+
import arcade
9+
from arcade import ControllerManager, MOUSE_BUTTON_LEFT
10+
from arcade.gui import (
11+
ListProperty,
12+
Property,
13+
Surface,
14+
UIAnchorLayout,
15+
UIEvent,
16+
UIInteractiveWidget,
17+
UIManager,
18+
UIMousePressEvent,
19+
UIMouseReleaseEvent,
20+
UIWidget,
21+
bind,
22+
)
23+
24+
25+
@dataclass
26+
class UIControllerEvent(UIEvent):
27+
"""Base class for all UI controller events.
28+
29+
Args:
30+
source: The controller that triggered the event.
31+
"""
32+
33+
34+
@dataclass
35+
class UIControllerStickEvent(UIControllerEvent):
36+
"""Triggered when a controller stick is moved.
37+
38+
Args:
39+
name: The name of the stick.
40+
vector: The value of the stick.
41+
"""
42+
43+
name: str
44+
vector: Vec2
45+
46+
47+
@dataclass
48+
class UIControllerTriggerEvent(UIControllerEvent):
49+
"""Triggered when a controller trigger is moved.
50+
51+
Args:
52+
name: The name of the trigger.
53+
value: The value of the trigger.
54+
"""
55+
56+
name: str
57+
value: float
58+
59+
60+
@dataclass
61+
class UIControllerButtonPressEvent(UIControllerEvent):
62+
"""Triggered when a controller button is pressed.
63+
64+
Args:
65+
button: The name of the button.
66+
"""
67+
68+
button: str
69+
70+
71+
@dataclass
72+
class UIControllerButtonReleaseEvent(UIControllerEvent):
73+
"""Triggered when a controller button is released.
74+
75+
Args:
76+
button: The name of the button.
77+
"""
78+
79+
button: str
80+
81+
82+
@dataclass
83+
class UIControllerDpadEvent(UIControllerEvent):
84+
"""Triggered when a controller dpad is moved.
85+
86+
Args:
87+
vector: The value of the dpad.
88+
"""
89+
90+
vector: Vec2
91+
92+
93+
class ControllerListener:
94+
"""Interface for listening to controller events"""
95+
96+
def on_stick_motion(self, controller: Controller, name: str, value: Vec2):
97+
pass
98+
99+
def on_trigger_motion(self, controller: Controller, name: str, value: float):
100+
pass
101+
102+
def on_button_press(self, controller: Controller, button_name: str):
103+
pass
104+
105+
def on_button_release(self, controller: Controller, button_name: str):
106+
pass
107+
108+
def on_dpad_motion(self, controller: Controller, value: Vec2):
109+
pass
110+
111+
112+
class UIControllerBridge(ControllerListener):
113+
"""Translates controller events to UIEvents and passes them to the UIManager
114+
115+
Controller events are not consumed by the UIControllerBridge,
116+
so they can be used by other systems.
117+
118+
#TODO change this
119+
This implicates, that the UIControllerBridge should be the first listener in the chain and
120+
that other systems should be aware, when not to act on events (like when the UI is active).
121+
"""
122+
123+
def __init__(self, ui: UIManager):
124+
self.ui = ui
125+
self.cm = ControllerManager()
126+
127+
self.cm.push_handlers(self)
128+
# bind to existing controllers
129+
for controller in self.cm.get_controllers():
130+
print("Controller connected", controller)
131+
self.on_connect(controller)
132+
133+
def on_connect(self, controller: Controller):
134+
controller.push_handlers(self)
135+
controller.open()
136+
137+
def on_disconnect(self, controller: Controller):
138+
controller.remove_handlers(self)
139+
controller.close()
140+
141+
# Controller event mapping
142+
def on_stick_motion(self, controller: Controller, name, value):
143+
self.ui.dispatch_ui_event(UIControllerStickEvent(controller, name, value))
144+
145+
def on_trigger_motion(self, controller: Controller, name, value):
146+
self.ui.dispatch_ui_event(UIControllerTriggerEvent(controller, name, value))
147+
148+
def on_button_press(self, controller: Controller, button):
149+
self.ui.dispatch_ui_event(UIControllerButtonPressEvent(controller, button))
150+
151+
def on_button_release(self, controller: Controller, button):
152+
self.ui.dispatch_ui_event(UIControllerButtonReleaseEvent(controller, button))
153+
154+
def on_dpad_motion(self, controller: Controller, value):
155+
self.ui.dispatch_ui_event(UIControllerDpadEvent(controller, value))
156+
157+
158+
class UIFocusGroup(UIAnchorLayout):
159+
"""A group of widgets that can be focused.
160+
161+
UIFocusGroup maintains two lists of widgets:
162+
- The list of focusable widgets.
163+
- The list of widgets in.
164+
165+
Use detect_focusable_widgets to automatically detect focusable widgets
166+
or add_widget to add them manually.
167+
168+
"""
169+
170+
_widgets = ListProperty[UIWidget]()
171+
_focused = Property(0)
172+
173+
def __init__(self, size_hint=(1, 1), **kwargs):
174+
super().__init__(size_hint=size_hint, **kwargs)
175+
176+
bind(self, "_focused", self.trigger_full_render)
177+
bind(self, "_widgets", self.trigger_full_render)
178+
179+
def on_event(self, event: UIEvent) -> Optional[bool]:
180+
181+
if super().on_event(event):
182+
return EVENT_HANDLED
183+
184+
if isinstance(event, UIControllerDpadEvent):
185+
if event.vector.x == 1 or event.vector.y == -1:
186+
self.focus_next()
187+
return EVENT_HANDLED
188+
elif event.vector.x == -1 or event.vector.y == 1:
189+
self.focus_previous()
190+
return EVENT_HANDLED
191+
192+
elif isinstance(event, UIControllerButtonPressEvent):
193+
if event.button == "a":
194+
self.start_interaction()
195+
return EVENT_HANDLED
196+
elif isinstance(event, UIControllerButtonReleaseEvent):
197+
if event.button == "a":
198+
self.end_interaction()
199+
return EVENT_HANDLED
200+
201+
return EVENT_UNHANDLED
202+
203+
def add_widget(self, widget):
204+
self._widgets.append(widget)
205+
206+
@classmethod
207+
def _walk_widgets(cls, root: UIWidget):
208+
for child in reversed(root.children):
209+
yield child
210+
yield from cls._walk_widgets(child)
211+
212+
def detect_focusable_widgets(self, root: UIWidget = None):
213+
"""Automatically detect focusable widgets."""
214+
if root is None:
215+
root = self
216+
217+
widgets = self._walk_widgets(root)
218+
219+
focusable_widgets = []
220+
for widget in reversed(list(widgets)):
221+
if self.is_focusable(widget):
222+
focusable_widgets.append(widget)
223+
224+
self._widgets = focusable_widgets
225+
226+
def focus_next(self):
227+
self._focused += 1
228+
if self._focused >= len(self._widgets):
229+
self._focused = 0
230+
231+
def focus_previous(self):
232+
self._focused -= 1
233+
if self._focused < 0:
234+
self._focused = len(self._widgets) - 1
235+
236+
def start_interaction(self):
237+
widget = self._widgets[self._focused]
238+
239+
if isinstance(widget, UIInteractiveWidget):
240+
widget.dispatch_ui_event(
241+
UIMousePressEvent(
242+
source=self,
243+
x=widget.rect.center_x,
244+
y=widget.rect.center_y,
245+
button=MOUSE_BUTTON_LEFT,
246+
modifiers=0,
247+
)
248+
)
249+
else:
250+
print("Cannot interact widget")
251+
252+
def end_interaction(self):
253+
widget = self._widgets[self._focused]
254+
255+
if isinstance(widget, UIInteractiveWidget):
256+
widget.dispatch_ui_event(
257+
UIMouseReleaseEvent(
258+
source=self,
259+
x=widget.rect.center_x,
260+
y=widget.rect.center_y,
261+
button=MOUSE_BUTTON_LEFT,
262+
modifiers=0,
263+
)
264+
)
265+
266+
# TODO render after children rendered
267+
def do_render(self, surface: Surface):
268+
surface.limit(None)
269+
widget = self._widgets[self._focused]
270+
arcade.draw_rect_outline(
271+
rect=widget.rect,
272+
color=arcade.color.WHITE,
273+
border_width=2,
274+
)
275+
276+
@staticmethod
277+
def is_focusable(widget):
278+
return isinstance(widget, UIInteractiveWidget)

0 commit comments

Comments
 (0)