From 8062dc4bcf0415df31be960fe851a826db189781 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 29 Jul 2025 22:48:41 +0200 Subject: [PATCH 1/9] controller window skip initial events --- arcade/experimental/controller_window.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/arcade/experimental/controller_window.py b/arcade/experimental/controller_window.py index 2e6d9e2cba..933146ee25 100644 --- a/arcade/experimental/controller_window.py +++ b/arcade/experimental/controller_window.py @@ -29,12 +29,11 @@ def __init__(self, window: arcade.Window): 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}") + controller.push_handlers(self) self.window.dispatch_event("on_connect", controller) From 539cf00c6fe18713c93f7d190024c70326e8f2cb Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 29 Jul 2025 23:31:13 +0200 Subject: [PATCH 2/9] gui: move focus interaction into widget code --- arcade/gui/experimental/focus.py | 103 ++++--------------------------- arcade/gui/widgets/__init__.py | 49 +++++++++++++++ arcade/gui/widgets/slider.py | 27 +++++++- arcade/gui/widgets/text.py | 7 +++ 4 files changed, 93 insertions(+), 93 deletions(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index a41cd158d5..b6186da12f 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -5,23 +5,16 @@ 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 import FocusMode, UIWidget from arcade.gui.widgets.layout import UIAnchorLayout -from arcade.gui.widgets.slider import UIBaseSlider class UIFocusable(UIWidget): @@ -100,54 +93,22 @@ def on_event(self, event: UIEvent) -> bool | None: return EVENT_HANDLED - elif event.symbol == arcade.key.SPACE: - self._start_interaction() + elif isinstance(event, UIControllerDpadEvent): + # switch focus + if event.vector.x == 1: + self.focus_right() return EVENT_HANDLED - elif isinstance(event, UIKeyReleaseEvent): - if event.symbol == arcade.key.SPACE: - self._end_interaction() + elif event.vector.y == 1: + self.focus_up() 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 - + elif event.vector.x == -1: + self.focus_left() 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() + elif event.vector.y == -1: + self.focus_down() return EVENT_HANDLED return EVENT_UNHANDLED @@ -278,48 +239,6 @@ def focus_previous(self): # 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) diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 3d752dd39b..734b3e43e0 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -22,6 +22,10 @@ UIMouseReleaseEvent, UIOnClickEvent, UIOnUpdateEvent, + UIControllerButtonPressEvent, + UIControllerButtonReleaseEvent, + UIKeyPressEvent, + UIKeyReleaseEvent, ) from arcade.gui.nine_patch import NinePatchTexture from arcade.gui.property import ListProperty, Property, bind @@ -745,6 +749,13 @@ def __init__( bind(self, "pressed", UIInteractiveWidget.trigger_render) bind(self, "hovered", UIInteractiveWidget.trigger_render) bind(self, "disabled", UIInteractiveWidget.trigger_render) + bind(self, "focused", UIInteractiveWidget._on_focus_change) + + def _on_focus_change(self): + """If focus lost, release active state""" + if self.pressed and not self.focused: + self.pressed = False + self._release_active() def on_event(self, event: UIEvent) -> bool | None: """Handles mouse events and triggers on_click event if the widget is clicked. @@ -754,6 +765,7 @@ def on_event(self, event: UIEvent) -> bool | None: if super().on_event(event): return EVENT_HANDLED + # mouse event handling if isinstance(event, UIMouseMovementEvent): self.hovered = self.rect.point_in_rect(event.pos) @@ -788,6 +800,43 @@ def on_event(self, event: UIEvent) -> bool | None: ) return EVENT_HANDLED # TODO should we return the result from on_click? + # focus related events + if self.focused: + if isinstance(event, UIKeyPressEvent) and event.symbol == arcade.key.SPACE: + self.pressed = True + self._grap_active() # make this the active widget + return EVENT_HANDLED + + if isinstance(event, UIControllerButtonPressEvent) and event.button in ("a",): + self.pressed = True + self._grap_active() # make this the active widget + return EVENT_HANDLED + + if self.pressed: + keyboard_interaction = ( + isinstance(event, UIKeyReleaseEvent) and event.symbol == arcade.key.SPACE + ) + controller_interaction = isinstance( + event, UIControllerButtonReleaseEvent + ) and event.button in ("a",) + + if keyboard_interaction or controller_interaction: + self.pressed = False + if not self.disabled: + # Dispatch new on_click event, source is this widget itself + self._grap_active() + self.dispatch_event( + "on_click", + UIOnClickEvent( # simulate mouse click + source=self, + x=int(self.center_x), + y=int(self.center_y), + button=self.interaction_buttons[0], + modifiers=0, + ), + ) + return EVENT_HANDLED # TODO should we return the result from on_click? + return EVENT_UNHANDLED def on_click(self, event: UIOnClickEvent): diff --git a/arcade/gui/widgets/slider.py b/arcade/gui/widgets/slider.py index 9d2ccb545b..45c44ffe55 100644 --- a/arcade/gui/widgets/slider.py +++ b/arcade/gui/widgets/slider.py @@ -18,7 +18,11 @@ UIMouseDragEvent, UIOnClickEvent, ) -from arcade.gui.events import UIOnChangeEvent +from arcade.gui.events import ( + UIControllerButtonReleaseEvent, + UIControllerDpadEvent, + UIOnChangeEvent, +) from arcade.gui.property import Property, bind from arcade.gui.style import UIStyleBase, UIStyledWidget from arcade.types import RGBA255 @@ -223,6 +227,15 @@ def on_event(self, event: UIEvent) -> bool | None: if self.disabled: return EVENT_UNHANDLED + # handle UIControllerButtonEvent events, + # before UIInteractiveWidgets dispatches an on_click event + if self.focused and isinstance(event, UIControllerButtonReleaseEvent): + if event.button == "a": + self.pressed = False + self._release_active() + + return EVENT_HANDLED + if super().on_event(event): return EVENT_HANDLED @@ -232,6 +245,18 @@ def on_event(self, event: UIEvent) -> bool | None: return EVENT_HANDLED + if self.pressed and isinstance(event, UIControllerDpadEvent): + # pass dpad events to the interacting widget + if event.vector.x == 1: + self.norm_value += 0.1 + return EVENT_HANDLED + + elif event.vector.x == -1: + self.norm_value -= 0.1 + return EVENT_HANDLED + + return EVENT_HANDLED + return EVENT_UNHANDLED @override diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 224de9a7b1..24255ce266 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -574,11 +574,18 @@ def __init__( bind(self, "pressed", UIInputText._apply_style) bind(self, "invalid", UIInputText._apply_style) bind(self, "disabled", UIInputText._apply_style) + bind(self, "focused", UIInputText._on_focus_change) bind(self, "_active", UIInputText._on_active_changed) # initial style application self._apply_style() + def _on_focus_change(self): + if self.focused: + self.activate() + elif self.active: + self.deactivate() + def _on_active_changed(self): """Handle the active state change of the input text field to care about loosing active state.""" From 144863a07b534ce4ad0daa154b91ff42e6c49322 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 29 Jul 2025 23:32:09 +0200 Subject: [PATCH 3/9] gui: add escape handler to close example --- arcade/examples/gui/exp_controller_support.py | 9 ++++++++- arcade/examples/gui/exp_controller_support_grid.py | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/arcade/examples/gui/exp_controller_support.py b/arcade/examples/gui/exp_controller_support.py index 40dd979722..26decc42fa 100644 --- a/arcade/examples/gui/exp_controller_support.py +++ b/arcade/examples/gui/exp_controller_support.py @@ -9,7 +9,6 @@ python -m arcade.examples.gui.exp_controller_support """ - import arcade from arcade import Texture from arcade.experimental.controller_window import ControllerWindow, ControllerView @@ -143,6 +142,7 @@ def __init__(self): root.add(UIFlatButton(text="Close")).on_click = self.close self.detect_focusable_widgets() + self.set_focus() def on_event(self, event): if super().on_event(event): @@ -190,6 +190,13 @@ def on_button_click(self, event: UIOnClickEvent): print("Button clicked") self.root.add(ControllerModal()) + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + # make the example close with the escape key + if symbol == arcade.key.ESCAPE: + self.window.close() + return True + return super().on_key_press(symbol, modifiers) + if __name__ == "__main__": window = ControllerWindow(title="Controller UI Example") diff --git a/arcade/examples/gui/exp_controller_support_grid.py b/arcade/examples/gui/exp_controller_support_grid.py index 51f1dd0772..9ea4cad632 100644 --- a/arcade/examples/gui/exp_controller_support_grid.py +++ b/arcade/examples/gui/exp_controller_support_grid.py @@ -9,7 +9,6 @@ python -m arcade.examples.gui.exp_controller_support_grid """ - import arcade from arcade.examples.gui.exp_controller_support import ControllerIndicator from arcade.experimental.controller_window import ControllerView, ControllerWindow @@ -88,6 +87,13 @@ def __init__(self): self.root.detect_focusable_widgets() + def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + # make the example close with the escape key + if symbol == arcade.key.ESCAPE: + self.window.close() + return True + return super().on_key_press(symbol, modifiers) + if __name__ == "__main__": window = ControllerWindow(title="Controller UI Example") From 72d1801588e370ba4c978e7c117c6ac3e36eee24 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 29 Jul 2025 23:32:25 +0200 Subject: [PATCH 4/9] gui: make use of focus group to switch between input fields --- arcade/examples/gui/exp_hidden_password.py | 33 +++++++++------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/arcade/examples/gui/exp_hidden_password.py b/arcade/examples/gui/exp_hidden_password.py index 8c83ffeff5..9cc981e823 100644 --- a/arcade/examples/gui/exp_hidden_password.py +++ b/arcade/examples/gui/exp_hidden_password.py @@ -13,10 +13,12 @@ """ import arcade +from arcade.experimental.controller_window import ControllerWindow from arcade.gui import UIInputText, UIOnClickEvent, UIView +from arcade.gui.experimental.focus import UIFocusGroup from arcade.gui.experimental.password_input import UIPasswordInput from arcade.gui.widgets.buttons import UIFlatButton -from arcade.gui.widgets.layout import UIGridLayout, UIAnchorLayout +from arcade.gui.widgets.layout import UIGridLayout from arcade.gui.widgets.text import UILabel from arcade import resources @@ -80,32 +82,25 @@ def __init__(self): column_span=2, ) - anchor = UIAnchorLayout() # to center grid on screen + anchor = UIFocusGroup() # to center grid on screen anchor.add(grid) self.add_widget(anchor) # activate username input field - self.username_input.activate() + anchor.detect_focusable_widgets() + anchor.set_focus() def on_key_press(self, symbol: int, modifiers: int) -> bool | None: + # make the example close with the escape key + if symbol == arcade.key.ESCAPE: + self.window.close() + return True + # if username field active, switch fields with enter - if self.username_input.active: - if symbol == arcade.key.TAB: - self.username_input.deactivate() - self.password_input.activate() - return True - elif symbol == arcade.key.ENTER: + elif self.username_input.active or self.password_input.active: + if symbol == arcade.key.ENTER: self.username_input.deactivate() - self.on_login(None) - return True - # if password field active, login with enter - elif self.password_input.active: - if symbol == arcade.key.TAB: - self.username_input.activate() - self.password_input.deactivate() - return True - elif symbol == arcade.key.ENTER: self.password_input.deactivate() self.on_login(None) return True @@ -118,7 +113,7 @@ def on_login(self, event: UIOnClickEvent | None): def main(): - window = arcade.Window(title="GUI Example: Hidden Password") + window = ControllerWindow(title="GUI Example: Hidden Password") window.show_view(MyView()) window.run() From 1e0a28ae8b409b1bc6097b6bf50aee7b4d9aedea Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 29 Jul 2025 23:40:22 +0200 Subject: [PATCH 5/9] gui: remove debug prints --- arcade/gui/widgets/text.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 24255ce266..6919039e5a 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -719,10 +719,7 @@ def deactivate(self): """Programmatically deactivate the text input field.""" if self._active: - print("Release active text input field") self._release_active() # will set _active to False - else: - print("Text input field is not active, cannot deactivate") self.trigger_full_render() self.caret.on_deactivate() From 137a06d7aac03566c59ee0ae77546420547d05f4 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 29 Jul 2025 23:43:11 +0200 Subject: [PATCH 6/9] gui: fix flickering of UIInputText when focused and space pressed --- arcade/gui/widgets/text.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index 6919039e5a..f57fbc519f 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -23,6 +23,7 @@ UITextInputEvent, UITextMotionEvent, UITextMotionSelectEvent, + UIKeyEvent, ) from arcade.gui.property import Property, bind from arcade.gui.style import UIStyleBase, UIStyledWidget @@ -670,6 +671,12 @@ def on_event(self, event: UIEvent) -> bool | None: # If active pass all non press events to caret if self._active: old_text = self.text + + if self.focused and isinstance(event, UIKeyEvent) and event.symbol == arcade.key.SPACE: + # if widget is focused, we consume the space key + # to prevent flickering of the focus + return EVENT_HANDLED + # Act on events if active if isinstance(event, UITextInputEvent): self.caret.on_text(event.text) From 62ddb615e42791d79d1daca3c842d3a6ffc9a210 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Tue, 29 Jul 2025 23:56:45 +0200 Subject: [PATCH 7/9] gui: update UIFocusGroup docs --- arcade/gui/experimental/focus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index b6186da12f..c6dafa9a1d 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -292,4 +292,4 @@ def is_focusable(widget): class UIFocusGroup(UIFocusMixin, UIAnchorLayout): - pass + """This will be removed in the future. UIFocusMixin is planned to be integrated into UILayout.""" From 719f31ba0d596b1ecb8401928c4b6f2d7ed01eb0 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Wed, 30 Jul 2025 00:03:43 +0200 Subject: [PATCH 8/9] add change log --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba77e10b1a..97d8ef61b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - GUI - Fix a bug, where the caret of UIInputText was misplaced after resizing the widget - Use incremental layout for UIScrollArea to improve performance of changing text + - Refactored and improved focus handling ## 3.3.2 From 23773c7ecbc63d5e7e760ee1f16b3eb7b003e851 Mon Sep 17 00:00:00 2001 From: Maic Siemering Date: Wed, 30 Jul 2025 00:43:45 +0200 Subject: [PATCH 9/9] lint --- arcade/gui/experimental/focus.py | 3 ++- arcade/gui/widgets/text.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index c6dafa9a1d..333f461a57 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -292,4 +292,5 @@ def is_focusable(widget): class UIFocusGroup(UIFocusMixin, UIAnchorLayout): - """This will be removed in the future. UIFocusMixin is planned to be integrated into UILayout.""" + """This will be removed in the future. + UIFocusMixin is planned to be integrated into UILayout.""" diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index f57fbc519f..c19e198be1 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -13,6 +13,7 @@ from arcade import uicolor from arcade.gui.events import ( UIEvent, + UIKeyEvent, UIMouseDragEvent, UIMouseEvent, UIMousePressEvent, @@ -23,7 +24,6 @@ UITextInputEvent, UITextMotionEvent, UITextMotionSelectEvent, - UIKeyEvent, ) from arcade.gui.property import Property, bind from arcade.gui.style import UIStyleBase, UIStyledWidget