diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 953a84a4e6..dd6633f90d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,8 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.10 + # Ruff version. + rev: v0.11.4 hooks: # Run the linter. - id: ruff @@ -21,7 +22,11 @@ repos: hooks: - id: mypy args: [ --explicit-package-bases ] + language: system + exclude: ^tests/ - repo: https://github.com/RobertCraigie/pyright-python rev: v1.1.396 hooks: - id: pyright + language: system + exclude: ^tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e36ddc20fb..4ebd825d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Support `layer` in `UIView.add_widget()` - Fix a bug which caused `UIScrollArea` to refresh on every frame - Add stepping to `UISlider` (thanks [csd4ni3l](https://github.com/csd4ni3l)) + - Experimental controller support (incl. documentation) - Text objects are now lazy and can be created before the window - Introduce `arcade.SpriteSequence[T]` as a covariant supertype of `arcade.SpriteList[T]` (this is similar to Python's `Sequence[T]`, which is a supertype of `list[T]`) diff --git a/arcade/examples/gui/exp_controller_inventory.py b/arcade/examples/gui/exp_controller_inventory.py new file mode 100644 index 0000000000..11a9724504 --- /dev/null +++ b/arcade/examples/gui/exp_controller_inventory.py @@ -0,0 +1,424 @@ +""" + +Example of a full functional inventory system. + +This example demonstrates how to create a simple inventory system. + +Main features are: +- Inventory slots +- Equipment slots +- Move items between slots +- Controller support + +If Arcade and Python are properly installed, you can run this example with: +python -m arcade.examples.gui.exp_controller_inventory +""" + +# TODO: Drag and Drop +from typing import List, Optional + +import pyglet.font +from pyglet.event import EVENT_HANDLED +from pyglet.gl import GL_NEAREST +from pyglet.input import Controller + +import arcade +from arcade import Rect +from arcade.examples.gui.exp_controller_support_grid import ( + ControllerIndicator, + setup_grid_focus_transition, +) +from arcade.experimental.controller_window import ControllerWindow, ControllerView +from arcade.gui import ( + Property, + Surface, + UIAnchorLayout, + UIBoxLayout, + UIFlatButton, + UIGridLayout, + UILabel, + UIOnClickEvent, + UIView, + UIWidget, + bind, + UIEvent, +) +from arcade.gui.events import UIControllerButtonPressEvent +from arcade.gui.experimental.focus import UIFocusable, UIFocusGroup +from arcade.resources import load_kenney_fonts + + +class Item: + """Base class for all items.""" + + def __init__(self, symbol: str): + self.symbol = symbol + + +class Inventory: + """ + Basic inventory class. + + Contains items and manages items. + + + inventory = Inventory(10) + inventory.add(Item("🍎")) + inventory.add(Item("🍌")) + inventory.add(Item("🍇")) + + + for item in inventory: + print(item.symbol) + + inventory.remove(inventory[0]) + """ + + def __init__(self, capacity: int): + self._items: List[Item | None] = [None for _ in range(capacity)] + self.capacity = capacity + + def add(self, item: Item): + empty_slot = None + for i, slot in enumerate(self._items): + if slot is None: + empty_slot = i + break + + if empty_slot is not None: + self._items[empty_slot] = item + else: + raise ValueError("Inventory is full.") + + def is_full(self): + return len(self._items) == self.capacity + + def remove(self, item: Item): + for i, slot in enumerate(self._items): + if slot == item: + self._items[i] = None + return + + def __getitem__(self, index: int): + return self._items[index] + + def __setitem__(self, index: int, value: Item): + self._items[index] = value + + def __iter__(self): + yield from self._items + + +class Equipment(Inventory): + """Equipment inventory. + + Contains three slots for head, chest and legs. + """ + + def __init__(self): + super().__init__(3) + + @property + def head(self) -> Item: + return self[0] + + @head.setter + def head(self, value): + self[0] = value + + @property + def chest(self) -> Item: + return self[1] + + @chest.setter + def chest(self, value): + self[1] = value + + @property + def legs(self) -> Item: + return self[2] + + @legs.setter + def legs(self, value): + self[2] = value + + +class InventorySlotUI(UIFocusable, UIFlatButton): + """Represents a single inventory slot. + The slot accesses a specific index in the inventory. + + Emits an on_click event. + """ + + def __init__(self, inventory: Inventory, index: int, **kwargs): + super().__init__(size_hint=(1, 1), **kwargs) + self.ui_label.update_font(font_size=24) + self._inventory = inventory + self._index = index + + item = inventory[index] + if item: + self.text = item.symbol + + @property + def item(self) -> Item | None: + return self._inventory[self._index] + + @item.setter + def item(self, value): + self._inventory[self._index] = value + self._on_item_change() + + def _on_item_change(self, *args): + if self.item: + self.text = self.item.symbol + else: + self.text = "" + + +class EquipmentSlotUI(InventorySlotUI): + pass + + +class InventoryUI(UIGridLayout): + """Manages inventory slots. + + Emits an `on_slot_clicked(slot)` event when a slot is clicked. + + """ + + def __init__(self, inventory: Inventory, **kwargs): + super().__init__( + size_hint=(0.7, 1), + column_count=6, + row_count=5, + align_vertical="center", + align_horizontal="center", + vertical_spacing=10, + horizontal_spacing=10, + **kwargs, + ) + self.with_padding(all=10) + self.with_border(color=arcade.color.WHITE, width=2) + + self.inventory = inventory + self.grid = {} + + for i, item in enumerate(inventory): + slot = InventorySlotUI(inventory, i) + # fill left to right, bottom to top (6x5 grid) + self.add(slot, column=i % 6, row=i // 6) + self.grid[(i % 6, i // 6)] = slot + slot.on_click = self._on_slot_click # type: ignore + + InventoryUI.register_event_type("on_slot_clicked") + + def _on_slot_click(self, event: UIOnClickEvent): + # propagate slot click event to parent + self.dispatch_event("on_slot_clicked", event.source) + + def on_slot_clicked(self, event: UIOnClickEvent): + pass + + +class EquipmentUI(UIBoxLayout): + """Contains three slots for equipment items. + + - Head + - Chest + - Legs + + Emits an `on_slot_clicked(slot)` event when a slot is clicked. + + """ + + def __init__(self, **kwargs): + super().__init__(size_hint=(0.3, 1), space_between=10, **kwargs) + self.with_padding(all=20) + self.with_border(color=arcade.color.WHITE, width=2) + + equipment = Equipment() + + self.head_slot = self.add(EquipmentSlotUI(equipment, 0)) + self.head_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.head_slot) + + self.chest_slot = self.add(EquipmentSlotUI(equipment, 1)) + self.chest_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.chest_slot) + + self.legs_slot = self.add(EquipmentSlotUI(equipment, 2)) + self.legs_slot.on_click = lambda _: self.dispatch_event("on_slot_clicked", self.legs_slot) + + EquipmentUI.register_event_type("on_slot_clicked") + + +class ActiveSlotTrackerMixin(UIWidget): + """ + Mixin class to track the active slot. + """ + + active_slot = Property[InventorySlotUI | None](None) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + bind(self, "active_slot", self.trigger_render) + + def do_render(self, surface: Surface): + surface.limit(None) + if self.active_slot: + rect: Rect = self.active_slot.rect + + rect = rect.resize(*(rect.size + (2, 2))) + arcade.draw_rect_outline(rect, arcade.uicolor.RED_ALIZARIN, 2) + + return super().do_render(surface) + + def on_slot_clicked(self, clicked_slot: InventorySlotUI): + if self.active_slot: + # disable active slot + if clicked_slot == self.active_slot: + self.active_slot = None + return + + else: + # swap items + src_item = self.active_slot.item + dst_item = clicked_slot.item + + self.active_slot.item = dst_item + clicked_slot.item = src_item + + self.active_slot = None + return + + else: + # activate slot if contains item + if clicked_slot.item: + self.active_slot = clicked_slot + + +class InventoryModal(ActiveSlotTrackerMixin, UIFocusGroup, UIAnchorLayout): + def __init__(self, inventory: Inventory, **kwargs): + super().__init__(size_hint=(0.8, 0.8), **kwargs) + self.with_padding(all=10) + self.with_background(color=arcade.uicolor.GREEN_GREEN_SEA) + self._debug = False + + self.add( + UILabel(text="Inventory", font_size=20, font_name="Kenney Blocks", bold=True), + anchor_y="top", + ) + + content = UIBoxLayout(size_hint=(1, 0.9), vertical=False, space_between=10) + self.add(content, anchor_y="bottom") + + inv_ui = content.add(InventoryUI(inventory)) + inv_ui.on_slot_clicked = self.on_slot_clicked # type: ignore + + eq_ui = content.add(EquipmentUI()) + eq_ui.on_slot_clicked = self.on_slot_clicked # type: ignore + + # prepare focusable widgets + widget_grid = inv_ui.grid + setup_grid_focus_transition( + widget_grid # type: ignore + ) # setup default transitions in a grid + + # add transitions to equipment slots + cols = max(x for x, y in widget_grid.keys()) + rows = max(y for x, y in widget_grid.keys()) + + equipment_slots = [eq_ui.head_slot, eq_ui.chest_slot, eq_ui.legs_slot] + + # connect inventory slots with equipment slots + slots_to_eq_ratio = (rows + 1) / len(equipment_slots) + for i in range(rows + 1): + eq_index = int(i // slots_to_eq_ratio) + eq_slot = equipment_slots[eq_index] + + inv_slot = widget_grid[(cols, i)] + + inv_slot.neighbor_right = eq_slot + eq_slot.neighbor_left = inv_slot + + # close button not part of the normal focus rotation, but can be focused with "b" + self.close_button = self.add( + # todo: find out why X is not in center + UIFlatButton(text="X", width=40, height=40), + anchor_x="right", + anchor_y="top", + ) + self.close_button.on_click = lambda _: self.close() # type: ignore + + # init controller support + self.detect_focusable_widgets() + + def on_event(self, event: UIEvent) -> Optional[bool]: + if isinstance(event, UIControllerButtonPressEvent): + if event.button == "b": + self.set_focus(self.close_button) + return EVENT_HANDLED + + return super().on_event(event) + + def close(self): + self.visible = False + self.trigger_full_render() + + +class MyView(UIView, ControllerView): + def __init__(self): + super().__init__() + + self.background_color = arcade.color.BLACK + + self.inventory = Inventory(30) + + self.inventory.add(Item("🍎")) + self.inventory.add(Item("🍌")) + self.inventory.add(Item("🍇")) + + self.root = self.add_widget(UIAnchorLayout()) + self.add_widget(ControllerIndicator()) + + text = self.root.add( + UILabel( + text="Open Inventory with 'Select' button on a controller or 'I' key", font_size=24 + ) + ) + text.fit_content() + text.center_on_screen() + + self._inventory_modal = self.root.add(InventoryModal(self.inventory)) + + def toggle_inventory(self): + self._inventory_modal.visible = not self._inventory_modal.visible + + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + if symbol == arcade.key.I: + self.toggle_inventory() + return True + + return super().on_key_press(symbol, modifiers) + + def on_button_press(self, controller: Controller, button): + if button == "back": + self.toggle_inventory() + return True + + return super().on_button_press(controller, button) + + def on_draw_before_ui(self): + pass + + +if __name__ == "__main__": + # pixelate the font + pyglet.font.base.Font.texture_min_filter = GL_NEAREST + pyglet.font.base.Font.texture_mag_filter = GL_NEAREST + + load_kenney_fonts() + + ControllerWindow(title="Minimal example", width=1280, height=720, resizable=True).show_view( + MyView() + ) + arcade.run() diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py new file mode 100644 index 0000000000..f85b207fbd --- /dev/null +++ b/arcade/examples/gui/exp_controller_support.py @@ -0,0 +1,198 @@ +""" +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.experimental.controller_window import ControllerWindow, ControllerView +from arcade.gui import ( + UIAnchorLayout, + UIBoxLayout, + UIDropdown, + UIEvent, + UIFlatButton, + UIImage, + UIMouseFilterMixin, + UIOnChangeEvent, + UIOnClickEvent, + UISlider, + UIView, +) +from arcade.gui.events import ( + 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", width=200)) + root.add(UIFlatButton(text="Modal Button 2", width=200)) + root.add(UIFlatButton(text="Modal Button 3", width=200)) + 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(ControllerView, UIView): + def __init__(self): + super().__init__() + arcade.set_background_color(arcade.color.AMAZON) + + 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 = ControllerWindow(title="Controller UI Example") + window.show_view(MyView()) + arcade.run() diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py new file mode 100644 index 0000000000..4833b0fcab --- /dev/null +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -0,0 +1,96 @@ +""" +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.experimental.controller_window import ControllerView, ControllerWindow +from arcade.gui import ( + UIFlatButton, + UIGridLayout, + UIView, + UIWidget, +) +from arcade.gui.experimental.focus import UIFocusable, UIFocusGroup + + +class FocusableButton(UIFocusable, 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, UIFocusable): + 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(ControllerView, UIView): + def __init__(self): + super().__init__() + arcade.set_background_color(arcade.color.AMAZON) + + 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 = ControllerWindow(title="Controller UI Example") + window.show_view(MyView()) + arcade.run() diff --git a/arcade/experimental/controller_window.py b/arcade/experimental/controller_window.py new file mode 100644 index 0000000000..2e6d9e2cba --- /dev/null +++ b/arcade/experimental/controller_window.py @@ -0,0 +1,154 @@ +import warnings + +from pyglet.input import Controller + +import arcade +from arcade import ControllerManager + + +class _WindowControllerBridge: + """Translates controller events to UIEvents and passes them to the UIManager. + + Controller are automatically connected and disconnected. + + Controller events are consumed by the UIControllerBridge, + if the UIEvent is consumed by the UIManager. + + This implicates, that the UIControllerBridge should be the first listener in the chain and + that other systems should be aware, when not to act on events (like when the UI is active). + """ + + def __init__(self, window: arcade.Window): + self.window = window + + self.cm = ControllerManager() + self.cm.push_handlers(self) + + # bind to existing controllers + for controller in self.cm.get_controllers(): + self.on_connect(controller) + + def on_connect(self, controller: Controller): + controller.push_handlers(self) + + try: + controller.open() + except Exception as e: + warnings.warn(f"Failed to open controller {controller}: {e}") + + self.window.dispatch_event("on_connect", controller) + + def on_disconnect(self, controller: Controller): + controller.remove_handlers(self) + + try: + controller.close() + except Exception as e: + warnings.warn(f"Failed to close controller {controller}: {e}") + + self.window.dispatch_event("on_disconnect", controller) + + # Controller input event mapping + def on_stick_motion(self, controller: Controller, name, value): + return self.window.dispatch_event("on_stick_motion", controller, name, value) + + def on_trigger_motion(self, controller: Controller, name, value): + return self.window.dispatch_event("on_trigger_motion", controller, name, value) + + def on_button_press(self, controller: Controller, button): + return self.window.dispatch_event("on_button_press", controller, button) + + def on_button_release(self, controller: Controller, button): + return self.window.dispatch_event("on_button_release", controller, button) + + def on_dpad_motion(self, controller: Controller, value): + return self.window.dispatch_event("on_dpad_motion", controller, value) + + +class ControllerWindow(arcade.Window): + """A window that automatically opens and listens to controller events + and dispatches them via on_... hooks.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cb = _WindowControllerBridge(self) + + def get_controllers(self) -> list[Controller]: + """Return a list of connected controllers.""" + return self.cb.cm.get_controllers() + + # Controller event mapping + def on_connect(self, controller: Controller): + """Called when a controller is connected. + The controller is already opened and ready to be used. + """ + pass + + def on_disconnect(self, controller: Controller): + """Called when a controller is disconnected.""" + pass + + def on_stick_motion(self, controller: Controller, name, value): + """Called when a stick is moved.""" + pass + + def on_trigger_motion(self, controller: Controller, name, value): + """Called when a trigger is moved.""" + pass + + def on_button_press(self, controller: Controller, button): + """Called when a button is pressed.""" + pass + + def on_button_release(self, controller: Controller, button): + """Called when a button is released.""" + pass + + def on_dpad_motion(self, controller: Controller, value): + """Called when the dpad is moved.""" + pass + + +ControllerWindow.register_event_type("on_connect") +ControllerWindow.register_event_type("on_disconnect") +ControllerWindow.register_event_type("on_stick_motion") +ControllerWindow.register_event_type("on_trigger_motion") +ControllerWindow.register_event_type("on_button_press") +ControllerWindow.register_event_type("on_button_release") +ControllerWindow.register_event_type("on_dpad_motion") + + +class ControllerView(arcade.View): + """A view which predefines the controller event mapping methods. + + Can be used with a ControllerWindow to handle controller events.""" + + def on_connect(self, controller: Controller): + """Called when a controller is connected. + The controller is already opened and ready to be used. + """ + pass + + def on_disconnect(self, controller: Controller): + """Called when a controller is disconnected.""" + pass + + def on_stick_motion(self, controller: Controller, name, value): + """Called when a stick is moved.""" + pass + + def on_trigger_motion(self, controller: Controller, name, value): + """Called when a trigger is moved.""" + pass + + def on_button_press(self, controller: Controller, button): + """Called when a button is pressed.""" + pass + + def on_button_release(self, controller: Controller, button): + """Called when a button is released.""" + pass + + def on_dpad_motion(self, controller: Controller, value): + """Called when the dpad is moved.""" + pass diff --git a/arcade/gl/texture.py b/arcade/gl/texture.py index 479fa23ce8..fd88e6217d 100644 --- a/arcade/gl/texture.py +++ b/arcade/gl/texture.py @@ -136,8 +136,8 @@ def __init__( self._component_size = 0 self._alignment = 1 self._target = target - self._samples = min(max(0, samples), self._ctx.info.MAX_SAMPLES) - self._depth = depth + self._samples: int = min(max(0, samples), self._ctx.info.MAX_SAMPLES) + self._depth: bool = depth self._immutable = immutable self._compare_func: str | None = None self._anisotropy = 1.0 diff --git a/arcade/gui/events.py b/arcade/gui/events.py index a150d1ce5a..27476d439c 100644 --- a/arcade/gui/events.py +++ b/arcade/gui/events.py @@ -236,3 +236,96 @@ class UIOnActionEvent(UIEvent): """ action: Any + + +@dataclass +class UIControllerEvent(UIEvent): + """Base class for all UI controller events. + + Args: + source: The controller that triggered the event. + """ + + +@dataclass +class UIControllerConnectEvent(UIControllerEvent): + """Triggered when a controller is connected. + + Args: + source: The controller that triggered the event. + """ + + +@dataclass +class UIControllerDisconnectEvent(UIControllerEvent): + """Triggered when a controller is disconnected. + + Args: + source: The controller that triggered the event. + """ + + +@dataclass +class UIControllerStickEvent(UIControllerEvent): + """Triggered when a controller stick is moved. + + Args: + name: The name of the stick. + vector: The value of the stick. + """ + + name: str + vector: Vec2 + + +@dataclass +class UIControllerTriggerEvent(UIControllerEvent): + """Triggered when a controller trigger is moved. + + Args: + name: The name of the trigger. + value: The value of the trigger. + """ + + name: str + value: float + + +@dataclass +class UIControllerButtonEvent(UIControllerEvent): + """Triggered when a controller button used. + + Args: + button: The name of the button. + """ + + button: str + + +@dataclass +class UIControllerButtonPressEvent(UIControllerButtonEvent): + """Triggered when a controller button is pressed. + + Args: + button: The name of the button. + """ + + +@dataclass +class UIControllerButtonReleaseEvent(UIControllerButtonEvent): + """Triggered when a controller button is released. + + Args: + button: The name of the button. + """ + + +@dataclass +class UIControllerDpadEvent(UIControllerEvent): + """Triggered when a controller dpad is moved. + + Args: + vector: The value of the dpad. + """ + + vector: Vec2 diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py new file mode 100644 index 0000000000..204c8efad6 --- /dev/null +++ b/arcade/gui/experimental/focus.py @@ -0,0 +1,373 @@ +import warnings +from types import EllipsisType +from typing import Optional + +from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED +from pyglet.math import Vec2 + +import arcade +from arcade import MOUSE_BUTTON_LEFT +from arcade.gui.events import ( + UIControllerButtonPressEvent, + UIControllerButtonReleaseEvent, + UIControllerDpadEvent, + UIControllerEvent, + UIEvent, + UIKeyPressEvent, + UIKeyReleaseEvent, + UIMousePressEvent, + UIMouseReleaseEvent, +) +from arcade.gui.property import ListProperty, Property, bind +from arcade.gui.surface import Surface +from arcade.gui.widgets import FocusMode, UIInteractiveWidget, UIWidget +from arcade.gui.widgets.layout import UIAnchorLayout +from arcade.gui.widgets.slider import UIBaseSlider + + +class UIFocusable(UIWidget): + """ + A widget that provides additional information about focus neighbors. + + Attributes: + + neighbor_up: The widget above this widget. + neighbor_right: The widget right of this widget. + neighbor_down: The widget below this widget. + neighbor_left: The widget left of this widget. + """ + + focus_mode = FocusMode.ALL + + neighbor_up: UIWidget | None = None + neighbor_right: UIWidget | None = None + neighbor_down: UIWidget | None = None + neighbor_left: UIWidget | None = None + + +class UIFocusMixin(UIWidget): + """A group of widgets that can be focused. + + UIFocusGroup maintains two lists of widgets: + - The list of focusable widgets. + - The list of widgets within (normal widget children). + + Use `detect_focusable_widgets()` to automatically detect focusable widgets + or explicitly use `add_widget()`. + + The Group can be navigated with the keyboard (TAB/ SHIFT + TAB) or controller (DPad). + + - DPAD: Navigate between focusable widgets. (up, down, left, right) + - TAB: Navigate between focusable widgets. + - 'A' Button or SPACE: Interact with the focused widget. + + """ + + _focused_widget = Property[UIWidget | None](None) + _focusable_widgets = ListProperty[UIWidget]() + _interacting: UIWidget | None = None + + _debug = Property(False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + bind(self, "_debug", self.trigger_full_render) + bind(self, "_focused_widget", self.trigger_full_render) + bind(self, "_focusable_widgets", self.trigger_full_render) + + def on_event(self, event: UIEvent) -> Optional[bool]: + # pass events to children first, including controller events + # so they can handle them + if super().on_event(event): + return EVENT_HANDLED + + if isinstance(event, UIControllerEvent): + # if no focused widget, set the first focusable widget + if self.focused_widget is None and self._focusable_widgets: + self.set_focus() + return EVENT_HANDLED + + if isinstance(event, UIKeyPressEvent): + if event.symbol == arcade.key.TAB: + if event.modifiers & arcade.key.MOD_SHIFT: + self.focus_previous() + else: + self.focus_next() + + return EVENT_HANDLED + + elif event.symbol == arcade.key.SPACE: + self._start_interaction() + return EVENT_HANDLED + + elif isinstance(event, UIKeyReleaseEvent): + if event.symbol == arcade.key.SPACE: + self._end_interaction() + return EVENT_HANDLED + + elif isinstance(event, UIControllerDpadEvent): + if self._interacting: + # TODO this should be handled in the slider! + # pass dpad events to the interacting widget + if event.vector.x == 1 and isinstance(self._interacting, UIBaseSlider): + self._interacting.norm_value += 0.1 + return EVENT_HANDLED + + elif event.vector.x == -1 and isinstance(self._interacting, UIBaseSlider): + self._interacting.norm_value -= 0.1 + return EVENT_HANDLED + + return EVENT_HANDLED + + else: + # switch focus + if event.vector.x == 1: + self.focus_right() + return EVENT_HANDLED + + elif event.vector.y == 1: + self.focus_up() + return EVENT_HANDLED + + elif event.vector.x == -1: + self.focus_left() + return EVENT_HANDLED + + elif event.vector.y == -1: + self.focus_down() + return EVENT_HANDLED + + elif isinstance(event, UIControllerButtonPressEvent): + if event.button == "a": + self._start_interaction() + return EVENT_HANDLED + elif isinstance(event, UIControllerButtonReleaseEvent): + if event.button == "a": + self._end_interaction() + return EVENT_HANDLED + + return EVENT_UNHANDLED + + @classmethod + def _walk_widgets(cls, root: UIWidget): + for child in reversed(root.children): + yield child + yield from cls._walk_widgets(child) + + def detect_focusable_widgets(self): + """Automatically detect focusable widgets.""" + widgets = self._walk_widgets(self) + + focusable_widgets = [] + for widget in reversed(list(widgets)): + if self.is_focusable(widget): + focusable_widgets.append(widget) + + self._focusable_widgets = focusable_widgets + + @property + def focused_widget(self) -> UIWidget | None: + """Return the currently focused widget. + If no widget is focused, return None.""" + return self._focused_widget + + def set_focus(self, widget: UIWidget | None | EllipsisType = ...): + """Set the focus to a specific widget. + + Set the focus to a specific widget. The widget must be in the list of + focusable widgets. If the widget is not in the list, a ValueError is raised. + + Setting the focus to None will remove the focus from the current widget. + If `...` is passed (default), the focus will be set to the first + focusable widget in the list. + + Args: + widget: The widget to focus. + """ + # de-focus the current widget + if widget is None: + if self.focused_widget is not None: + self.focused_widget.focused = False + self._focused_widget = None + return + + # resolve ... + if widget is Ellipsis: + if self._focusable_widgets: + widget = self._focusable_widgets[0] + else: + raise ValueError( + "No focusable widgets in the group, " + "use `detect_focusable_widgets()` to detect them." + ) + + # handle new focus + if widget not in self._focusable_widgets: + raise ValueError("Widget is not focusable or not in the group.") + + if self.focused_widget is not None: + self.focused_widget.focused = False + widget.focused = True + self._focused_widget = widget + + def focus_up(self): + widget = self.focused_widget + if isinstance(widget, UIFocusable): + if widget.neighbor_up: + self.set_focus(widget.neighbor_up) + return + + self.focus_previous() + + def focus_down(self): + widget = self.focused_widget + if isinstance(widget, UIFocusable): + if widget.neighbor_down: + self.set_focus(widget.neighbor_down) + return + + self.focus_next() + + def focus_left(self): + widget = self.focused_widget + if isinstance(widget, UIFocusable): + if widget.neighbor_left: + self.set_focus(widget.neighbor_left) + return + + self.focus_previous() + + def focus_right(self): + widget = self.focused_widget + if isinstance(widget, UIFocusable): + if widget.neighbor_right: + self.set_focus(widget.neighbor_right) + return + + self.focus_next() + + def focus_next(self): + """Focus the next widget in the list of focusable widgets of this group""" + if self.focused_widget is None: + warnings.warn("No focused widget. Do not change focus.") + return + + if self.focused_widget not in self._focusable_widgets: + warnings.warn("Focused widget not in focusable widgets list. Do not change focus.") + return + + focused_index = self._focusable_widgets.index(self.focused_widget) + 1 + focused_index %= len(self._focusable_widgets) # wrap around + self.set_focus(self._focusable_widgets[focused_index]) + + def focus_previous(self): + """Focus the previous widget in the list of focusable widgets of this group""" + if self.focused_widget is None: + warnings.warn("No focused widget. Do not change focus.") + return + + if self.focused_widget not in self._focusable_widgets: + warnings.warn("Focused widget not in focusable widgets list. Do not change focus.") + return + + focused_index = self._focusable_widgets.index(self.focused_widget) - 1 + # automatically wrap around via index -1 + self.set_focus(self._focusable_widgets[focused_index]) + + def _start_interaction(self): + # TODO this should be handled in the widget + + widget = self.focused_widget + + if isinstance(widget, UIInteractiveWidget): + widget.dispatch_ui_event( + UIMousePressEvent( + source=self, + x=int(widget.rect.center_x), + y=int(widget.rect.center_y), + button=MOUSE_BUTTON_LEFT, + modifiers=0, + ) + ) + self._interacting = widget + else: + print("Cannot interact widget") + + def _end_interaction(self): + widget = self.focused_widget + + if isinstance(widget, UIInteractiveWidget): + if isinstance(self._interacting, UIBaseSlider): + # if slider, release outside the slider + x = self._interacting.rect.left - 1 + y = self._interacting.rect.bottom - 1 + else: + x = widget.rect.center_x + y = widget.rect.center_y + + self._interacting = None + widget.dispatch_ui_event( + UIMouseReleaseEvent( + source=self, + x=int(x), + y=int(y), + button=MOUSE_BUTTON_LEFT, + modifiers=0, + ) + ) + + def _do_render(self, surface: Surface, force=False) -> bool: + rendered = super()._do_render(surface, force) + + if rendered: + self.do_post_render(surface) + + return rendered + + def do_post_render(self, surface: Surface): + surface.limit(None) + + widget = self.focused_widget + if not widget: + return + + if self._debug: + # debugging + if isinstance(widget, UIFocusable): + if widget.neighbor_up: + self._draw_indicator( + widget.rect.top_center, + widget.neighbor_up.rect.bottom_center, + color=arcade.color.RED, + ) + if widget.neighbor_down: + self._draw_indicator( + widget.rect.bottom_center, + widget.neighbor_down.rect.top_center, + color=arcade.color.GREEN, + ) + if widget.neighbor_left: + self._draw_indicator( + widget.rect.center_left, + widget.neighbor_left.rect.center_right, + color=arcade.color.BLUE, + ) + if widget.neighbor_right: + self._draw_indicator( + widget.rect.center_right, + widget.neighbor_right.rect.center_left, + color=arcade.color.ORANGE, + ) + + def _draw_indicator(self, start: Vec2, end: Vec2, color=arcade.color.WHITE): + arcade.draw_line(start.x, start.y, end.x, end.y, color, 2) + arcade.draw_circle_filled(end.x, end.y, 5, color, num_segments=4) + + @staticmethod + def is_focusable(widget): + return widget.focus_mode is not FocusMode.NONE + + +class UIFocusGroup(UIFocusMixin, UIAnchorLayout): + pass diff --git a/arcade/gui/experimental/password_input.py b/arcade/gui/experimental/password_input.py index a9b9ecc01c..63a36e4449 100644 --- a/arcade/gui/experimental/password_input.py +++ b/arcade/gui/experimental/password_input.py @@ -1,4 +1,8 @@ -from arcade.gui import Surface, UIEvent, UIInputText, UITextInputEvent +from __future__ import annotations + +from arcade.gui.events import UIEvent, UITextInputEvent +from arcade.gui.surface import Surface +from arcade.gui.widgets.text import UIInputText class UIPasswordInput(UIInputText): diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index eb3e6afbad..0b0a22e77f 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -6,21 +6,19 @@ import arcade from arcade import XYWH -from arcade.gui import ( - Property, - Surface, +from arcade.gui.events import ( UIEvent, - UIKeyPressEvent, - UILayout, UIMouseDragEvent, UIMouseEvent, UIMouseMovementEvent, UIMousePressEvent, UIMouseReleaseEvent, UIMouseScrollEvent, - UIWidget, - bind, ) +from arcade.gui.property import Property, bind +from arcade.gui.surface import Surface +from arcade.gui.widgets import UIWidget +from arcade.gui.widgets.layout import UILayout from arcade.types import LBWH W = TypeVar("W", bound="UIWidget") @@ -45,8 +43,6 @@ def __init__(self, scroll_area: UIScrollArea, vertical: bool = True): self.with_border(color=arcade.uicolor.GRAY_CONCRETE) self.vertical = vertical - # self._scroll_bar_size = 20 - bind(self, "_thumb_hover", self.trigger_render) bind(self, "_dragging", self.trigger_render) bind(scroll_area, "scroll_x", self.trigger_full_render) @@ -102,9 +98,6 @@ def on_event(self, event: UIEvent) -> bool | None: self._dragging = False return True - if isinstance(event, UIKeyPressEvent): - print(self._scroll_bar_size()) - return EVENT_UNHANDLED def _scroll_bar_size(self): diff --git a/arcade/gui/ui_manager.py b/arcade/gui/ui_manager.py index b97505ce3f..6d25be8bf7 100644 --- a/arcade/gui/ui_manager.py +++ b/arcade/gui/ui_manager.py @@ -12,11 +12,21 @@ from typing import Iterable, TypeVar, Union from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher +from pyglet.input import Controller +from pyglet.math import Vec2 from typing_extensions import TypeGuard import arcade +from arcade.experimental.controller_window import ControllerWindow from arcade.gui import UIEvent from arcade.gui.events import ( + UIControllerButtonPressEvent, + UIControllerButtonReleaseEvent, + UIControllerConnectEvent, + UIControllerDisconnectEvent, + UIControllerDpadEvent, + UIControllerStickEvent, + UIControllerTriggerEvent, UIKeyPressEvent, UIKeyReleaseEvent, UIMouseDragEvent, @@ -278,6 +288,20 @@ def enable(self) -> None: """ if not self._enabled: self._enabled = True + + if isinstance(self.window, ControllerWindow): + controller_handlers = { + self.on_connect, + self.on_disconnect, + self.on_stick_motion, + self.on_trigger_motion, + self.on_button_press, + self.on_button_release, + self.on_dpad_motion, + } + else: + controller_handlers = set() + self.window.push_handlers( self.on_resize, self.on_update, @@ -291,6 +315,7 @@ def enable(self) -> None: self.on_text, self.on_text_motion, self.on_text_motion_select, + *controller_handlers, ) def disable(self) -> None: @@ -301,6 +326,20 @@ def disable(self) -> None: """ if self._enabled: self._enabled = False + + if isinstance(self.window, ControllerWindow): + controller_handlers = { + self.on_connect, + self.on_disconnect, + self.on_stick_motion, + self.on_trigger_motion, + self.on_button_press, + self.on_button_release, + self.on_dpad_motion, + } + else: + controller_handlers = set() + self.window.remove_handlers( self.on_resize, self.on_update, @@ -314,6 +353,7 @@ def disable(self) -> None: self.on_text, self.on_text_motion, self.on_text_motion_select, + *controller_handlers, ) def on_update(self, time_delta): @@ -450,6 +490,29 @@ def on_resize(self, width, height): self.trigger_render() + def on_connect(self, controller: Controller): + """Called when a controller is connected.""" + self.dispatch_ui_event(UIControllerConnectEvent(controller)) + + def on_disconnect(self, controller: Controller): + """Called when a controller is disconnected.""" + self.dispatch_ui_event(UIControllerDisconnectEvent(controller)) + + def on_stick_motion(self, controller: Controller, name: str, value: Vec2): + return self.dispatch_ui_event(UIControllerStickEvent(controller, name, value)) + + def on_trigger_motion(self, controller: Controller, name: str, value: float): + return self.dispatch_ui_event(UIControllerTriggerEvent(controller, name, value)) + + def on_button_press(self, controller: Controller, button: str): + return self.dispatch_ui_event(UIControllerButtonPressEvent(controller, button)) + + def on_button_release(self, controller: Controller, button: str): + return self.dispatch_ui_event(UIControllerButtonReleaseEvent(controller, button)) + + def on_dpad_motion(self, controller: Controller, value: Vec2): + return self.dispatch_ui_event(UIControllerDpadEvent(controller, value)) + @property def rect(self) -> Rect: """The rect of the UIManager, which is the window size.""" diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index d03b8382e1..e2b0e69565 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -1,5 +1,8 @@ +from __future__ import annotations + from abc import ABC -from typing import Dict, Iterable, List, NamedTuple, Optional, TYPE_CHECKING, Tuple, TypeVar, Union +from enum import IntEnum +from typing import TYPE_CHECKING, Dict, Iterable, List, NamedTuple, Optional, Tuple, TypeVar, Union from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED, EventDispatcher from pyglet.math import Vec2 @@ -25,11 +28,22 @@ if TYPE_CHECKING: from arcade.gui.ui_manager import UIManager -__all__ = ["Surface", "UIDummy"] - W = TypeVar("W", bound="UIWidget") +class FocusMode(IntEnum): + """Defines the focus mode of a widget. + + 0: Not focusable + 1: Focusable + + We might support different focus modes in the future, but for now on/off is enough. + """ + + NONE = 0 + ALL = 2 + + class _ChildEntry(NamedTuple): child: "UIWidget" data: Dict @@ -57,6 +71,8 @@ class UIWidget(EventDispatcher, ABC): rect = Property(LBWH(0, 0, 1, 1)) visible = Property(True) + focused = Property(False) + focus_mode: FocusMode = FocusMode.NONE size_hint = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) size_hint_min = Property[Optional[Tuple[Optional[float], Optional[float]]]](None) @@ -107,6 +123,7 @@ def __init__( self.add(child) bind(self, "rect", self.trigger_full_render) + bind(self, "focused", self.trigger_full_render) bind( self, "visible", self.trigger_full_render ) # TODO maybe trigger_parent_render would be enough @@ -242,6 +259,8 @@ def _do_render(self, surface: Surface, force=False) -> bool: rendered = True self.do_render_base(surface) self.do_render(surface) + if self.focused: + self.do_render_focus(surface) self._requires_render = False # only render children if self is visible @@ -292,6 +311,15 @@ def do_render(self, surface: Surface): """ pass + def do_render_focus(self, surface: Surface): + """Render the widgets focus representation overlay`""" + self.prepare_render(surface) + arcade.draw_rect_outline( + rect=LBWH(0, 0, self.content_width, self.content_height), + color=arcade.color.WHITE, + border_width=4, + ) + def dispatch_ui_event(self, event: UIEvent): """Dispatch a :class:`UIEvent` using pyglet event dispatch mechanism""" return self.dispatch_event("on_event", event) @@ -314,6 +342,19 @@ def scale(self, factor: AsFloat, anchor: Vec2 = AnchorPoint.CENTER): """ self.rect = self.rect.scale(new_scale=factor, anchor=anchor) + def get_ui_manager(self) -> UIManager | None: + """The UIManager this widget is attached to. During creation, this will be None.""" + from arcade.gui.ui_manager import UIManager + + w: UIWidget | None = self + while w and w.parent: + parent = w.parent + if isinstance(parent, UIManager): + return parent + + w = parent + return None + @property def left(self) -> float: """Left coordinate of the widget""" @@ -550,6 +591,8 @@ class UIInteractiveWidget(UIWidget): the interaction (default: left mouse button) """ + focus_mode = FocusMode.ALL + # States hovered = Property(False) """True if the mouse is over the widget""" @@ -867,3 +910,6 @@ def color(self): @color.setter def color(self, value): self.with_background(color=value) + + +__all__ = ["Surface", "UIDummy", "FocusMode", "UIInteractiveWidget", "UIWidget"] diff --git a/arcade/gui/widgets/buttons.py b/arcade/gui/widgets/buttons.py index 22ffa4db2f..60136e5398 100644 --- a/arcade/gui/widgets/buttons.py +++ b/arcade/gui/widgets/buttons.py @@ -150,7 +150,7 @@ def get_current_state(self) -> str: return "disabled" elif self.pressed: return "press" - elif self.hovered: + elif self.hovered or self.focused: return "hover" else: return "normal" @@ -346,7 +346,7 @@ def get_current_state(self) -> str: return "disabled" elif self.pressed: return "press" - elif self.hovered: + elif self.hovered or self.focused: return "hover" else: return "normal" diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index a7aa6968e9..27c2332911 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -6,14 +6,15 @@ import arcade from arcade import uicolor from arcade.gui import UIEvent, UIMousePressEvent -from arcade.gui.events import UIOnChangeEvent, UIOnClickEvent +from arcade.gui.events import UIControllerButtonPressEvent, UIOnChangeEvent, UIOnClickEvent +from arcade.gui.experimental.focus import UIFocusMixin from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UILayout, UIWidget from arcade.gui.widgets.buttons import UIFlatButton from arcade.gui.widgets.layout import UIBoxLayout -class _UIDropdownOverlay(UIBoxLayout): +class _UIDropdownOverlay(UIFocusMixin, UIBoxLayout): """Represents the dropdown options overlay. Currently only handles closing the overlay when clicked outside of the options. @@ -26,6 +27,7 @@ def show(self, manager: UIManager): def hide(self): """Hide the overlay.""" + self.set_focus(None) if self.parent: self.parent.remove(self) @@ -35,6 +37,13 @@ def on_event(self, event: UIEvent) -> Optional[bool]: if not self.rect.point_in_rect((event.x, event.y)): self.hide() return EVENT_HANDLED + + if isinstance(event, UIControllerButtonPressEvent): + # TODO find a better and more generic way to handle controller events for this + if event.button == "b": + self.hide() + return EVENT_HANDLED + return super().on_event(event) @@ -186,17 +195,10 @@ def _update_options(self): ) button.on_click = self._on_option_click - def _find_ui_manager(self): - # search tree for UIManager - parent = self.parent - while isinstance(parent, UIWidget): - # - parent = parent.parent - - return parent if isinstance(parent, UIManager) else None + self._overlay.detect_focusable_widgets() def _show_overlay(self): - manager = self._find_ui_manager() + manager = self.get_ui_manager() if manager is None: raise Exception("UIDropdown could not find UIManager in its parents.") diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index b7c6ed0ab8..9d7fccede6 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -114,6 +114,21 @@ def _apply_step(self, value: float): return value + def _set_value(self, value: float): + # TODO changing the value itself should trigger `on_change` event + # current problem is, that the property does not pass the old value to listeners + if value < self.min_value: + value = self.min_value + elif value > self.max_value: + value = self.max_value + + if self.value == value: + return + + old_value = self.value + self.value = value + self.dispatch_event("on_change", UIOnChangeEvent(self, old_value, self.value)) + def _x_for_value(self, value: float): """Provides the x coordinate for the given value.""" @@ -129,7 +144,8 @@ def norm_value(self): @norm_value.setter def norm_value(self, value): """Normalized value between 0.0 and 1.0""" - self.value = min(value * (self.max_value - self.min_value) + self.min_value, self.max_value) + new_value = min(value * (self.max_value - self.min_value) + self.min_value, self.max_value) + self._set_value(new_value) @property def _thumb_x(self): @@ -214,9 +230,8 @@ def on_event(self, event: UIEvent) -> bool | None: if isinstance(event, UIMouseDragEvent): if self.pressed: - old_value = self.value self._thumb_x = event.x - self.dispatch_event("on_change", UIOnChangeEvent(self, old_value, self.value)) + return EVENT_HANDLED return EVENT_UNHANDLED diff --git a/arcade/resources/assets/input_prompt/xbox/button_a.png b/arcade/resources/assets/input_prompt/xbox/button_a.png new file mode 100755 index 0000000000..2399fc263b Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_a.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_a_outline.png b/arcade/resources/assets/input_prompt/xbox/button_a_outline.png new file mode 100755 index 0000000000..8dd7cb9f77 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_a_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_b.png b/arcade/resources/assets/input_prompt/xbox/button_b.png new file mode 100755 index 0000000000..c66ce33e02 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_b.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_b_outline.png b/arcade/resources/assets/input_prompt/xbox/button_b_outline.png new file mode 100755 index 0000000000..474d894f8b Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_b_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_back.png b/arcade/resources/assets/input_prompt/xbox/button_back.png new file mode 100755 index 0000000000..3318c6a5ad Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_back.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_back_icon.png b/arcade/resources/assets/input_prompt/xbox/button_back_icon.png new file mode 100755 index 0000000000..89c56da2c4 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_back_icon.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_back_icon_outline.png b/arcade/resources/assets/input_prompt/xbox/button_back_icon_outline.png new file mode 100755 index 0000000000..a9ee088896 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_back_icon_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_back_outline.png b/arcade/resources/assets/input_prompt/xbox/button_back_outline.png new file mode 100755 index 0000000000..f6f1c4fc21 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_back_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_color_a.png b/arcade/resources/assets/input_prompt/xbox/button_color_a.png new file mode 100755 index 0000000000..d9d8fbd8d5 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_color_a.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_color_a_outline.png b/arcade/resources/assets/input_prompt/xbox/button_color_a_outline.png new file mode 100755 index 0000000000..9699da0f5f Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_color_a_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_color_b.png b/arcade/resources/assets/input_prompt/xbox/button_color_b.png new file mode 100755 index 0000000000..b3b63c8bf7 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_color_b.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_color_b_outline.png b/arcade/resources/assets/input_prompt/xbox/button_color_b_outline.png new file mode 100755 index 0000000000..9b94fcb435 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_color_b_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_color_x.png b/arcade/resources/assets/input_prompt/xbox/button_color_x.png new file mode 100755 index 0000000000..803a3f15aa Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_color_x.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_color_x_outline.png b/arcade/resources/assets/input_prompt/xbox/button_color_x_outline.png new file mode 100755 index 0000000000..5b9eeb5a5a Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_color_x_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_color_y.png b/arcade/resources/assets/input_prompt/xbox/button_color_y.png new file mode 100755 index 0000000000..9f8b0e4c4e Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_color_y.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_color_y_outline.png b/arcade/resources/assets/input_prompt/xbox/button_color_y_outline.png new file mode 100755 index 0000000000..d661418779 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_color_y_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_menu.png b/arcade/resources/assets/input_prompt/xbox/button_menu.png new file mode 100755 index 0000000000..546418adf9 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_menu.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_menu_outline.png b/arcade/resources/assets/input_prompt/xbox/button_menu_outline.png new file mode 100755 index 0000000000..1848da3400 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_menu_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_share.png b/arcade/resources/assets/input_prompt/xbox/button_share.png new file mode 100755 index 0000000000..1722161ad5 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_share.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_share_outline.png b/arcade/resources/assets/input_prompt/xbox/button_share_outline.png new file mode 100755 index 0000000000..9f91417845 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_share_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_start.png b/arcade/resources/assets/input_prompt/xbox/button_start.png new file mode 100755 index 0000000000..907a954a2a Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_start.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_start_icon.png b/arcade/resources/assets/input_prompt/xbox/button_start_icon.png new file mode 100755 index 0000000000..ac6c97fa6e Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_start_icon.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_start_icon_outline.png b/arcade/resources/assets/input_prompt/xbox/button_start_icon_outline.png new file mode 100755 index 0000000000..140a848626 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_start_icon_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_start_outline.png b/arcade/resources/assets/input_prompt/xbox/button_start_outline.png new file mode 100755 index 0000000000..ae48df9cd7 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_start_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_view.png b/arcade/resources/assets/input_prompt/xbox/button_view.png new file mode 100755 index 0000000000..141fda1adc Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_view.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_view_outline.png b/arcade/resources/assets/input_prompt/xbox/button_view_outline.png new file mode 100755 index 0000000000..33e28fbe63 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_view_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_x.png b/arcade/resources/assets/input_prompt/xbox/button_x.png new file mode 100755 index 0000000000..b04ef414e9 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_x.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_x_outline.png b/arcade/resources/assets/input_prompt/xbox/button_x_outline.png new file mode 100755 index 0000000000..e5dcc05019 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_x_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_y.png b/arcade/resources/assets/input_prompt/xbox/button_y.png new file mode 100755 index 0000000000..29dd0acfe0 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_y.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/button_y_outline.png b/arcade/resources/assets/input_prompt/xbox/button_y_outline.png new file mode 100755 index 0000000000..affbcfa3c0 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/button_y_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/controller_xbox360.png b/arcade/resources/assets/input_prompt/xbox/controller_xbox360.png new file mode 100755 index 0000000000..d7b71da573 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/controller_xbox360.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/controller_xbox_adaptive.png b/arcade/resources/assets/input_prompt/xbox/controller_xbox_adaptive.png new file mode 100755 index 0000000000..3a3d0bc3ea Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/controller_xbox_adaptive.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/controller_xboxone.png b/arcade/resources/assets/input_prompt/xbox/controller_xboxone.png new file mode 100755 index 0000000000..33c8b75977 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/controller_xboxone.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/controller_xboxseries.png b/arcade/resources/assets/input_prompt/xbox/controller_xboxseries.png new file mode 100755 index 0000000000..8c0a195465 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/controller_xboxseries.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad.png b/arcade/resources/assets/input_prompt/xbox/dpad.png new file mode 100755 index 0000000000..8090e775a1 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_all.png b/arcade/resources/assets/input_prompt/xbox/dpad_all.png new file mode 100755 index 0000000000..7da72adb8b Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_all.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_down.png b/arcade/resources/assets/input_prompt/xbox/dpad_down.png new file mode 100755 index 0000000000..49e2442754 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_down.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_down_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_down_outline.png new file mode 100755 index 0000000000..f31d0a5c8a Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_down_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_horizontal.png b/arcade/resources/assets/input_prompt/xbox/dpad_horizontal.png new file mode 100755 index 0000000000..09dba4e577 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_horizontal.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_horizontal_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_horizontal_outline.png new file mode 100755 index 0000000000..5f7094ace2 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_horizontal_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_left.png b/arcade/resources/assets/input_prompt/xbox/dpad_left.png new file mode 100755 index 0000000000..681aee87b3 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_left.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_left_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_left_outline.png new file mode 100755 index 0000000000..c813a64ae1 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_left_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_none.png b/arcade/resources/assets/input_prompt/xbox/dpad_none.png new file mode 100755 index 0000000000..d36e045f23 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_none.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_right.png b/arcade/resources/assets/input_prompt/xbox/dpad_right.png new file mode 100755 index 0000000000..0f874acfe7 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_right.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_right_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_right_outline.png new file mode 100755 index 0000000000..0c60966d78 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_right_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round.png b/arcade/resources/assets/input_prompt/xbox/dpad_round.png new file mode 100755 index 0000000000..ad2ca781bc Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_round.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_all.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_all.png new file mode 100755 index 0000000000..81941b5eed Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_round_all.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_down.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_down.png new file mode 100755 index 0000000000..3dddd32954 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_round_down.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_horizontal.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_horizontal.png new file mode 100755 index 0000000000..f19d5c08b7 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_round_horizontal.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_left.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_left.png new file mode 100755 index 0000000000..a031aa25cb Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_round_left.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_right.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_right.png new file mode 100755 index 0000000000..c234462bd2 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_round_right.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_up.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_up.png new file mode 100755 index 0000000000..f18f718957 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_round_up.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_round_vertical.png b/arcade/resources/assets/input_prompt/xbox/dpad_round_vertical.png new file mode 100755 index 0000000000..b1c36ae9a0 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_round_vertical.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_up.png b/arcade/resources/assets/input_prompt/xbox/dpad_up.png new file mode 100755 index 0000000000..7b103dae86 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_up.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_up_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_up_outline.png new file mode 100755 index 0000000000..0aa2b5779d Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_up_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_vertical.png b/arcade/resources/assets/input_prompt/xbox/dpad_vertical.png new file mode 100755 index 0000000000..123229ba99 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_vertical.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/dpad_vertical_outline.png b/arcade/resources/assets/input_prompt/xbox/dpad_vertical_outline.png new file mode 100755 index 0000000000..d919814ee8 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/dpad_vertical_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/guide.png b/arcade/resources/assets/input_prompt/xbox/guide.png new file mode 100755 index 0000000000..ddeb758df7 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/guide.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/guide_outline.png b/arcade/resources/assets/input_prompt/xbox/guide_outline.png new file mode 100755 index 0000000000..0f9c949779 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/guide_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/lb.png b/arcade/resources/assets/input_prompt/xbox/lb.png new file mode 100755 index 0000000000..b7b55df791 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/lb.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/lb_outline.png b/arcade/resources/assets/input_prompt/xbox/lb_outline.png new file mode 100755 index 0000000000..4b3527fcca Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/lb_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/ls.png b/arcade/resources/assets/input_prompt/xbox/ls.png new file mode 100755 index 0000000000..cb6eb93afb Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/ls.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/ls_outline.png b/arcade/resources/assets/input_prompt/xbox/ls_outline.png new file mode 100755 index 0000000000..337091d1f7 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/ls_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/lt.png b/arcade/resources/assets/input_prompt/xbox/lt.png new file mode 100755 index 0000000000..ad5c30a744 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/lt.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/lt_outline.png b/arcade/resources/assets/input_prompt/xbox/lt_outline.png new file mode 100755 index 0000000000..8792f46608 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/lt_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/rb.png b/arcade/resources/assets/input_prompt/xbox/rb.png new file mode 100755 index 0000000000..6582f391cd Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/rb.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/rb_outline.png b/arcade/resources/assets/input_prompt/xbox/rb_outline.png new file mode 100755 index 0000000000..e8a78e81d1 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/rb_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/rs.png b/arcade/resources/assets/input_prompt/xbox/rs.png new file mode 100755 index 0000000000..c729cde45a Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/rs.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/rs_outline.png b/arcade/resources/assets/input_prompt/xbox/rs_outline.png new file mode 100755 index 0000000000..7ea3310ae1 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/rs_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/rt.png b/arcade/resources/assets/input_prompt/xbox/rt.png new file mode 100755 index 0000000000..6702730918 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/rt.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/rt_outline.png b/arcade/resources/assets/input_prompt/xbox/rt_outline.png new file mode 100755 index 0000000000..862cbb2972 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/rt_outline.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l.png b/arcade/resources/assets/input_prompt/xbox/stick_l.png new file mode 100755 index 0000000000..2fcd5fb6b5 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_l.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_down.png b/arcade/resources/assets/input_prompt/xbox/stick_l_down.png new file mode 100755 index 0000000000..a4f93be20a Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_l_down.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_horizontal.png b/arcade/resources/assets/input_prompt/xbox/stick_l_horizontal.png new file mode 100755 index 0000000000..c513f8dfd4 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_l_horizontal.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_left.png b/arcade/resources/assets/input_prompt/xbox/stick_l_left.png new file mode 100755 index 0000000000..1cab90bf85 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_l_left.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_press.png b/arcade/resources/assets/input_prompt/xbox/stick_l_press.png new file mode 100755 index 0000000000..79cacbd275 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_l_press.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_right.png b/arcade/resources/assets/input_prompt/xbox/stick_l_right.png new file mode 100755 index 0000000000..256df0ce0a Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_l_right.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_up.png b/arcade/resources/assets/input_prompt/xbox/stick_l_up.png new file mode 100755 index 0000000000..aa1ac6a066 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_l_up.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_l_vertical.png b/arcade/resources/assets/input_prompt/xbox/stick_l_vertical.png new file mode 100755 index 0000000000..7b2e6aa84c Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_l_vertical.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r.png b/arcade/resources/assets/input_prompt/xbox/stick_r.png new file mode 100755 index 0000000000..852e140575 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_r.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_down.png b/arcade/resources/assets/input_prompt/xbox/stick_r_down.png new file mode 100755 index 0000000000..1930ed6801 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_r_down.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_horizontal.png b/arcade/resources/assets/input_prompt/xbox/stick_r_horizontal.png new file mode 100755 index 0000000000..f7fa95fb6a Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_r_horizontal.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_left.png b/arcade/resources/assets/input_prompt/xbox/stick_r_left.png new file mode 100755 index 0000000000..0d6b849e5e Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_r_left.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_press.png b/arcade/resources/assets/input_prompt/xbox/stick_r_press.png new file mode 100755 index 0000000000..faed4c47ff Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_r_press.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_right.png b/arcade/resources/assets/input_prompt/xbox/stick_r_right.png new file mode 100755 index 0000000000..0473f3ac6a Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_r_right.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_up.png b/arcade/resources/assets/input_prompt/xbox/stick_r_up.png new file mode 100755 index 0000000000..98d7d1e4a9 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_r_up.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_r_vertical.png b/arcade/resources/assets/input_prompt/xbox/stick_r_vertical.png new file mode 100755 index 0000000000..5d2cfe94bb Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_r_vertical.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_side_l.png b/arcade/resources/assets/input_prompt/xbox/stick_side_l.png new file mode 100755 index 0000000000..863fff6251 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_side_l.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_side_r.png b/arcade/resources/assets/input_prompt/xbox/stick_side_r.png new file mode 100755 index 0000000000..d0079aca6d Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_side_r.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_top_l.png b/arcade/resources/assets/input_prompt/xbox/stick_top_l.png new file mode 100755 index 0000000000..4d2ff9d870 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_top_l.png differ diff --git a/arcade/resources/assets/input_prompt/xbox/stick_top_r.png b/arcade/resources/assets/input_prompt/xbox/stick_top_r.png new file mode 100755 index 0000000000..04e57d8693 Binary files /dev/null and b/arcade/resources/assets/input_prompt/xbox/stick_top_r.png differ diff --git a/doc/programming_guide/gui/controller_support.rst b/doc/programming_guide/gui/controller_support.rst new file mode 100644 index 0000000000..d443632f0b --- /dev/null +++ b/doc/programming_guide/gui/controller_support.rst @@ -0,0 +1,198 @@ +.. _gui_controller_support: + +GUI Controller Support +---------------------- + +The `arcade.gui` module now includes **experimental controller support**, allowing you to navigate through GUI elements using a game controller. This feature introduces the `ControllerWindow` and `ControllerView` classes, which provide controller-specific functionality. + +Below is a guide on how to set up and use this feature effectively. + +Basic Setup +~~~~~~~~~~~ + +To use controller support, you need to use the `ControllerWindow` and `ControllerView` classes. +These classes provide the necessary hooks for handling controller input and managing focus within the GUI. + +The following code makes use of the `UIView` class, which simplifies the process of setting up a view with a `UIManager`. + +Setting Up Controller Support +````````````````````````````` + +The `ControllerWindow` is an instance of `arcade.Window` that integrates controller input handling. The `ControllerView` class provides controller-specific callbacks, +which are used by the `UIManager` to handle controller events. + +Below is an example of how to set up a controller-enabled application: + +.. code-block:: python + + import arcade + from arcade.gui import UIView + from arcade.experimental.controller_window import ControllerWindow, ControllerView + + + class MyControllerView(ControllerView, UIView): + def __init__(self): + super().__init__() + + # Initialize your GUI elements here + + # react to controller events for your game + def on_connect(self, controller): + print(f"Controller connected: {controller}") + + def on_disconnect(self, controller): + print(f"Controller disconnected: {controller}") + + def on_stick_motion(self, controller, stick, value): + print(f"Stick {stick} moved to {value} on controller {controller}") + + def on_trigger_motion(self, controller, trigger, value): + print(f"Trigger {trigger} moved to {value} on controller {controller}") + + def on_button_press(self, controller, button): + print(f"Button {button} pressed on controller {controller}") + + def on_button_release(self, controller, button): + print(f"Button {button} released on controller {controller}") + + def on_dpad_motion(self, controller, value): + print(f"D-Pad moved to {value} on controller {controller}") + + + if __name__ == "__main__": + window = ControllerWindow(title="Controller Support Example") + view = MyControllerView() + window.show_view(view) + arcade.run() + + +Managing Focus with `FocusGroups` +````````````````````````````````` + +To enable controller navigation, you must group your interactive GUI elements into a `UIFocusGroup`. +A `UIFocusGroup` allows the controller to cycle through the elements and ensures that only one element is focused at a time. + +A single `UIFocusGroup` can be added to the `UIManager` as a root widget acting as a `UIAnchorLayout`. + +.. code-block:: python + + from arcade.experimental.controller_window import ControllerView, ControllerWindow + from arcade.gui import UIFlatButton, UIBoxLayout, UIView + from arcade.gui.experimental.focus import UIFocusGroup + + + class MyControllerView(ControllerView, UIView): + def __init__(self): + super().__init__() + + # Create buttons and add them to the focus group + fg = UIFocusGroup() + self.ui.add(fg) + + box = UIBoxLayout() + fg.add(box) + + button1 = UIFlatButton(text="Button 1", width=200) + button2 = UIFlatButton(text="Button 2", width=200) + + box.add(button1) + box.add(button2) + + # initialize the focus group, detect focusable widgets and set the initial focus + fg.detect_focusable_widgets() + fg.set_focus() + + + if __name__ == "__main__": + window = ControllerWindow(title="Controller Support Example") + view = MyControllerView() + window.show_view(view) + window.run() + + +Setting Initial Focus +````````````````````` + +It is essential to set the initial focus for the `UIFocusGroup`. Without this, the controller will not know which element to start with. + +.. code-block:: python + + # Set the initial focus + self.focus_group.set_focus() + +Summary +``````` +To use the experimental controller support in `arcade.gui`: + +1. Use `ControllerWindow` as your main application window. +2. Use `ControllerView` to provide controller-specific callbacks for the `UIManager`. +3. Group interactive elements into a `UIFocusGroup` for navigation. +4. Set the initial focus for the `UIFocusGroup` to enable proper navigation. + +This setup allows you to create a fully functional GUI that can be navigated using a game controller. Note that this feature is experimental and may be subject to changes in future releases. + + +Advanced Usage +~~~~~~~~~~~~~~ + +Nested `UIFocusGroups` +`````````````````````` + +When using nested `UIFocusGroups`, only one `UIFocusGroup` will be navigated at a time. +This is particularly useful for scenarios like modals or overlays, where you want to temporarily restrict navigation to +a specific set of elements. For example, the `UIDropdown` widget uses this feature to handle focus within its dropdown +menu while isolating it from the rest of the interface. + + +Advanced focus direction +```````````````````````` + +To provide a more advanced focus direction, you can use the `UIFocusable` class. + +The `UIFocusable` class allows you to define directional neighbors (`neighbor_up`, `neighbor_down`, `neighbor_left`, `neighbor_right`) for a widget. +These neighbors determine how focus moves between widgets when navigating with a controller or keyboard. + +Here is an example of how to use the `UIFocusable` class: + +.. code-block:: python + + from arcade.gui import UIFlatButton, UIGridLayout + from arcade.gui.experimental.focus import UIFocusGroup, UIFocusable + + class MyButton(UIFlatButton, UIFocusable): + def __init__(self, text, width): + super().__init__(text=text, width=width) + + + # Create focusable buttons + button1 = MyButton(text="Button 1", width=200) + button2 = MyButton(text="Button 2", width=200) + button3 = MyButton(text="Button 3", width=200) + button4 = MyButton(text="Button 4", width=200) + + # Set directional neighbors + button1.neighbor_right = button2 + button1.neighbor_down = button3 + button2.neighbor_left = button1 + button2.neighbor_down = button4 + button3.neighbor_up = button1 + button3.neighbor_right = button4 + button4.neighbor_up = button2 + button4.neighbor_left = button3 + + # Add buttons to a focus group + fg = UIFocusGroup() + + grid_layout = UIGridLayout(column_count=2, row_count=2, vertical_spacing=10) + grid_layout.add(button1, col_num=0, row_num=0) + grid_layout.add(button2, col_num=1, row_num=0) + grid_layout.add(button3, col_num=0, row_num=1) + grid_layout.add(button4, col_num=1, row_num=1) + + fg.add(grid_layout) + + # Detect focusable widgets and set the initial focus + fg.detect_focusable_widgets() + fg.set_focus(button1) + +This setup allows you to define custom navigation paths between widgets, enabling more complex focus behavior. diff --git a/doc/programming_guide/gui/index.rst b/doc/programming_guide/gui/index.rst index 44129d87bd..c3a1179a14 100644 --- a/doc/programming_guide/gui/index.rst +++ b/doc/programming_guide/gui/index.rst @@ -35,6 +35,4 @@ Find the required information in the following sections: style own_widgets own_layout - - - + controller_support diff --git a/tests/integration/examples/test_line_lengths.py b/tests/integration/examples/test_line_lengths.py index 50b65d42a2..5682c61539 100644 --- a/tests/integration/examples/test_line_lengths.py +++ b/tests/integration/examples/test_line_lengths.py @@ -1,6 +1,6 @@ """ Examples should never exceed a certain line length to ensure readability -in the documentation. The source code gets clipped after 90 ish characters. +in the documentation. The source code gets clipped after 100 ish characters. Adapted from util/check_example_line_length.py """ @@ -29,7 +29,7 @@ def is_ignored(path: Path): def test_line_lengths(): paths = EXAMPLE_ROOT.glob("**/*.py") - regex = re.compile("^.{100}.*$") + regex = re.compile("^.{100}.+$") grand_total = 0 file_count = 0 @@ -42,7 +42,7 @@ def test_line_lengths(): with open(path, encoding="utf8") as f: for line in f: line_no += 1 - result = regex.search(line.strip("\r")) + result = regex.search(line.strip("\r").strip("\n")) if result: print(f" {path.relative_to(EXAMPLE_ROOT)}:{line_no}: " + line.strip()) grand_total += 1 diff --git a/tests/unit/gui/test_focus.py b/tests/unit/gui/test_focus.py new file mode 100644 index 0000000000..ce38fc9153 --- /dev/null +++ b/tests/unit/gui/test_focus.py @@ -0,0 +1,80 @@ +from pyglet.math import Vec2 + +from arcade.gui import UIFlatButton +from arcade.gui.experimental.focus import UIFocusGroup + + +def test_focus_group_no_focus_set_by_default(ui): + group = UIFocusGroup() + _ = group.add(UIFlatButton()) + + group.detect_focusable_widgets() + + assert group.focused_widget is None + +def test_focus_group_focus_set(ui): + group = UIFocusGroup() + + assert group.focused_widget is None + btn_1 = group.add(UIFlatButton()) + btn_2 = group.add(UIFlatButton()) + + group.detect_focusable_widgets() + + group.set_focus(btn_1) + + assert group.focused_widget == btn_1 + assert btn_1.focused is True + assert btn_2.focused is False + +def test_nested_groups_button_press(ui): + """ + Test when nested UIFocusGroups are used. + + The inner group should consume the focus event and not pass it to the outer group. + """ + + group_1 = ui.add(UIFocusGroup()) + btn_1 = group_1.add(UIFlatButton()) + + group_2 = group_1.add(UIFocusGroup()) + btn_2 = group_2.add(UIFlatButton()) + + group_1.detect_focusable_widgets() + group_2.detect_focusable_widgets() + + group_1.set_focus(btn_1) + group_2.set_focus(btn_2) + + ui.on_button_press(None, "a") + + assert btn_1.pressed is False + assert btn_2.pressed is True + +def test_nested_groups_dpad(ui): + """ + Test when nested UIFocusGroups are used. + + The inner group should consume the focus event and not pass it to the outer group. + """ + + group_1 = ui.add(UIFocusGroup()) + btn_1_1 = group_1.add(UIFlatButton()) + btn_1_2 = group_1.add(UIFlatButton()) + + group_2 = group_1.add(UIFocusGroup()) + btn_2_1 = group_2.add(UIFlatButton()) + btn_2_2 = group_2.add(UIFlatButton()) + + group_1.detect_focusable_widgets() + group_2.detect_focusable_widgets() + + group_1.set_focus(btn_1_1) + group_2.set_focus(btn_2_1) + + ui.on_dpad_motion(None, Vec2(0, 1)) + + assert btn_1_1.focused is True + assert btn_1_2.focused is False + assert btn_2_1.focused is False + assert btn_2_2.focused is True diff --git a/tests/unit/resources/test_list_resources.py b/tests/unit/resources/test_list_resources.py index c499e4c8a5..e62f1015ac 100644 --- a/tests/unit/resources/test_list_resources.py +++ b/tests/unit/resources/test_list_resources.py @@ -10,12 +10,12 @@ def test_all(): resources = arcade.resources.list_built_in_assets() - assert len(resources) == pytest.approx(770, abs=10) + assert len(resources) == pytest.approx(863, abs=10) def test_png(): resources = arcade.resources.list_built_in_assets(extensions=(".png",)) - assert len(resources) == pytest.approx(630, abs=10) + assert len(resources) == pytest.approx(723, abs=10) def test_audio():