Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gui/controller #2566

Draft
wants to merge 23 commits into
base: development
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
538c9d5
gui:controller support draft
eruvanos Feb 6, 2025
fe0254a
experimental controller support incl inventory example
eruvanos Feb 14, 2025
1e96b4e
more robust controller connection handling, just in case
eruvanos Mar 7, 2025
a81ca2b
fix lib imports and extract UIFocusMixin from UIFocusGroup
eruvanos Mar 7, 2025
818c5ad
make UIDropDown support controller
eruvanos Mar 7, 2025
7489e4c
wip slider support
eruvanos Mar 7, 2025
91ec397
UISlider dispatch on_change when changed via dpad, this is only a wor…
eruvanos Mar 7, 2025
500d240
reset focus when out of range
eruvanos Mar 7, 2025
4711cf5
controller example listen to slider changes
eruvanos Mar 7, 2025
9eaa542
fix focus out of range
eruvanos Mar 7, 2025
7dc467d
Add UIFocusMixin do_post_render None widget handling (#2605)
csd4ni3l Mar 8, 2025
8db8913
adding more workarounds to handle scrollarea setups, set focused on w…
eruvanos Mar 8, 2025
3107eaa
introduce ControllerWindow, UIManager accepts controller input from w…
eruvanos Mar 8, 2025
895c8b7
Add missing file
eruvanos Mar 9, 2025
adfa0ab
Fix a bug where there are less widgets than focus index
eruvanos Mar 9, 2025
bab1b29
fix tests and type hints
eruvanos Mar 9, 2025
06fdf14
add missing resources
eruvanos Mar 9, 2025
a2dab0c
Add method to access connected controllers
eruvanos Mar 25, 2025
4b32537
fix linter
eruvanos Mar 28, 2025
782c652
Merge remote-tracking branch 'refs/remotes/origin/development' into g…
eruvanos Mar 28, 2025
be976c4
fix linter
eruvanos Mar 28, 2025
aac8c70
Merge branch 'development' into gui/controller
eruvanos Mar 29, 2025
0dc37b4
add ControllerView and dispatch on_connect/disconnect events
eruvanos Mar 29, 2025
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
200 changes: 200 additions & 0 deletions arcade/examples/gui/exp_controller_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"""
Example demonstrating controller support in an Arcade GUI.

This example shows how to integrate controller input with the Arcade GUI framework.
It includes a controller indicator widget that displays the last controller input,
and a modal dialog that can be navigated using a controller.

If Arcade and Python are properly installed, you can run this example with:
python -m arcade.examples.gui.exp_controller_support
"""

from typing import Optional

import arcade
from arcade import Texture
from arcade.gui import (
UIAnchorLayout,
UIBoxLayout,
UIDropdown,
UIEvent,
UIFlatButton,
UIImage,
UIMouseFilterMixin,
UIOnChangeEvent,
UIOnClickEvent,
UISlider,
UIView,
)
from arcade.gui.experimental.controller import (
UIControllerBridge,
UIControllerButtonEvent,
UIControllerButtonPressEvent,
UIControllerDpadEvent,
UIControllerEvent,
UIControllerStickEvent,
UIControllerTriggerEvent,
)
from arcade.gui.experimental.focus import UIFocusGroup
from arcade.types import Color


class ControllerIndicator(UIAnchorLayout):
"""
A widget that displays the last controller input.
"""

BLANK_TEX = Texture.create_empty("empty", (40, 40), arcade.color.TRANSPARENT_BLACK)
TEXTURE_CACHE: dict[str, Texture] = {}

def __init__(self):
super().__init__()

self._indicator = self.add(UIImage(texture=self.BLANK_TEX), anchor_y="bottom", align_y=10)
self._indicator.with_background(color=Color(0, 0, 0, 0))
self._indicator._strong_background = True

@classmethod
def get_texture(cls, path: str) -> Texture:
if path not in cls.TEXTURE_CACHE:
cls.TEXTURE_CACHE[path] = arcade.load_texture(path)
return cls.TEXTURE_CACHE[path]

@classmethod
def input_prompts(cls, event: UIControllerEvent) -> Texture | None:
if isinstance(event, UIControllerButtonEvent):
match event.button:
case "a":
return cls.get_texture(":resources:input_prompt/xbox/button_a.png")
case "b":
return cls.get_texture(":resources:input_prompt/xbox/button_b.png")
case "x":
return cls.get_texture(":resources:input_prompt/xbox/button_x.png")
case "y":
return cls.get_texture(":resources:input_prompt/xbox/button_y.png")
case "rightshoulder":
return cls.get_texture(":resources:input_prompt/xbox/rb.png")
case "leftshoulder":
return cls.get_texture(":resources:input_prompt/xbox/lb.png")
case "start":
return cls.get_texture(":resources:input_prompt/xbox/button_start.png")
case "back":
return cls.get_texture(":resources:input_prompt/xbox/button_back.png")

if isinstance(event, UIControllerTriggerEvent):
match event.name:
case "lefttrigger":
return cls.get_texture(":resources:input_prompt/xbox/lt.png")
case "righttrigger":
return cls.get_texture(":resources:input_prompt/xbox/rt.png")

if isinstance(event, UIControllerDpadEvent):
match event.vector:
case (1, 0):
return cls.get_texture(":resources:input_prompt/xbox/dpad_right.png")
case (-1, 0):
return cls.get_texture(":resources:input_prompt/xbox/dpad_left.png")
case (0, 1):
return cls.get_texture(":resources:input_prompt/xbox/dpad_up.png")
case (0, -1):
return cls.get_texture(":resources:input_prompt/xbox/dpad_down.png")

if isinstance(event, UIControllerStickEvent) and event.vector.length() > 0.2:
stick = "l" if event.name == "leftstick" else "r"

# map atan2(y, x) to direction string (up, down, left, right)
heading = event.vector.heading()
if 0.785 > heading > -0.785:
return cls.get_texture(f":resources:input_prompt/xbox/stick_{stick}_right.png")
elif 0.785 < heading < 2.356:
return cls.get_texture(f":resources:input_prompt/xbox/stick_{stick}_up.png")
elif heading > 2.356 or heading < -2.356:
return cls.get_texture(f":resources:input_prompt/xbox/stick_{stick}_left.png")
elif -2.356 < heading < -0.785:
return cls.get_texture(f":resources:input_prompt/xbox/stick_{stick}_down.png")

return None

def on_event(self, event: UIEvent) -> Optional[bool]:
if isinstance(event, UIControllerEvent):
input_texture = self.input_prompts(event)

if input_texture:
self._indicator.texture = input_texture

arcade.unschedule(self.reset)
arcade.schedule_once(self.reset, 0.5)

return super().on_event(event)

def reset(self, *_):
self._indicator.texture = self.BLANK_TEX


class ControllerModal(UIMouseFilterMixin, UIFocusGroup):
def __init__(self):
super().__init__(size_hint=(0.8, 0.8))
self.with_background(color=arcade.uicolor.DARK_BLUE_MIDNIGHT_BLUE)

root = self.add(UIBoxLayout(space_between=10))

root.add(UIFlatButton(text="Modal Button 1"))
root.add(UIFlatButton(text="Modal Button 2"))
root.add(UIFlatButton(text="Modal Button 3"))
root.add(UIFlatButton(text="Close")).on_click = self.close

self.detect_focusable_widgets()

def on_event(self, event):
if super().on_event(event):
return True

if isinstance(event, UIControllerButtonPressEvent):
if event.button == "b":
self.close(None)
return True

return False

def close(self, event):
print("Close")
# self.trigger_full_render()
self.trigger_full_render()
self.parent.remove(self)


class MyView(UIView):
def __init__(self):
super().__init__()
arcade.set_background_color(arcade.color.AMAZON)

self.controller_bridge = UIControllerBridge(self.ui)

base = self.add_widget(ControllerIndicator())
self.root = base.add(UIFocusGroup())
self.root.with_padding(left=10)
box = self.root.add(UIBoxLayout(space_between=10), anchor_x="left")

box.add(UIFlatButton(text="Button 1")).on_click = self.on_button_click
box.add(UIFlatButton(text="Button 2")).on_click = self.on_button_click
box.add(UIFlatButton(text="Button 3")).on_click = self.on_button_click

box.add(UIDropdown(default="Option 1", options=["Option 1", "Option 2", "Option 3"]))

slider = box.add(UISlider(value=0.5, min_value=0, max_value=1, width=200))

@slider.event
def on_change(event: UIOnChangeEvent):
print(f"Slider value changed: {event}")

self.root.detect_focusable_widgets()

def on_button_click(self, event: UIOnClickEvent):
print("Button clicked")
self.root.add(ControllerModal())


if __name__ == "__main__":
window = arcade.Window(title="Controller UI Example")
window.show_view(MyView())
arcade.run()
100 changes: 100 additions & 0 deletions arcade/examples/gui/exp_controller_support_grid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""
Example demonstrating a grid layout with focusable buttons in an Arcade GUI.

This example shows how to create a grid layout with buttons
that can be navigated using a controller.
It includes a focus transition setup to allow smooth navigation between buttons in the grid.

If Arcade and Python are properly installed, you can run this example with:
python -m arcade.examples.gui.exp_controller_support_grid
"""

from typing import Dict, Tuple

import arcade
from arcade.examples.gui.exp_controller_support import ControllerIndicator
from arcade.gui import (
UIFlatButton,
UIGridLayout,
UIView,
UIWidget,
)
from arcade.gui.experimental.controller import (
UIControllerBridge,
)
from arcade.gui.experimental.focus import Focusable, UIFocusGroup


class FocusableButton(Focusable, UIFlatButton):
pass


def setup_grid_focus_transition(grid: Dict[Tuple[int, int], UIWidget]):
"""Setup focus transition in grid.

Connect focus transition between `Focusable` in grid.

Args:
grid: Dict[Tuple[int, int], Focusable]: grid of Focusable widgets.
key represents position in grid (x,y)

"""

cols = max(x for x, y in grid.keys()) + 1
rows = max(y for x, y in grid.keys()) + 1
for c in range(cols):
for r in range(rows):
btn = grid.get((c, r))
if btn is None or not isinstance(btn, Focusable):
continue

if c > 0:
btn.neighbor_left = grid.get((c - 1, r))
else:
btn.neighbor_left = grid.get((cols - 1, r))

if c < cols - 1:
btn.neighbor_right = grid.get((c + 1, r))
else:
btn.neighbor_right = grid.get((0, r))

if r > 0:
btn.neighbor_up = grid.get((c, r - 1))
else:
btn.neighbor_up = grid.get((c, rows - 1))

if r < rows - 1:
btn.neighbor_down = grid.get((c, r + 1))
else:
btn.neighbor_down = grid.get((c, 0))


class MyView(UIView):
def __init__(self):
super().__init__()
arcade.set_background_color(arcade.color.AMAZON)

self.controller_bridge = UIControllerBridge(self.ui)

self.root = self.add_widget(ControllerIndicator())
self.root = self.root.add(UIFocusGroup())
grid = self.root.add(
UIGridLayout(column_count=3, row_count=3, vertical_spacing=10, horizontal_spacing=10)
)

_grid = {}
for i in range(9):
btn = FocusableButton(text=f"Button {i}")
_grid[(i % 3, i // 3)] = btn
grid.add(btn, column=i % 3, row=i // 3)

# connect focus transition in grid
setup_grid_focus_transition(_grid)

self.root.detect_focusable_widgets()


if __name__ == "__main__":
window = arcade.Window(title="Controller UI Example")
window.show_view(MyView())
arcade.run()
Loading
Loading