diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d392685..9e5887b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check. - Added the `sprite_pixel_demolition` example, a stress test where every pixel of the Arcade logo is a sprite (28,000, or 71,000 with smaller pixels) and exploding bullets knock them loose as debris. Debris can move as Python sprites or on the GPU with a shader (press G), which keeps 60 FPS with 15,000 pieces in flight. - Added `arcade.sweep_line(start, end, sprite_list)`, which returns a `SweepInfo` for the first sprite a line hits (its `fraction`, `distance`, and surface `normal`), or `None`. It's `sweep_sprite` for a point: for lasers, hitscan weapons, or seeing what's in the way. Added the `sprite_laser_mirrors` example, where a laser bounces off rotating mirrors using the normal. +- GUI: `UIScrollArea` now scrolls to the focused widget when focus moves inside a `UIFocusGroup`, for example with TAB or a controller's D-pad, so the focused widget is always visible. Nested scroll areas all scroll. The new `UIScrollArea.scroll_to_widget()` does this for any widget in the scroll area ([#2745](https://github.com/pythonarcade/arcade/issues/2745)). ### Misc Changes - Ruff's pyupgrade rules are on for `arcade` and `tests`. Type annotations use `list`, `dict`, `X | Y` and `collections.abc` instead of the deprecated `typing` aliases, mostly in `arcade.gl` ([#2676](https://github.com/pythonarcade/arcade/issues/2676)). Nothing changes at runtime. diff --git a/arcade/gui/experimental/focus.py b/arcade/gui/experimental/focus.py index 333f461a5..100c19f73 100644 --- a/arcade/gui/experimental/focus.py +++ b/arcade/gui/experimental/focus.py @@ -11,6 +11,7 @@ UIEvent, UIKeyPressEvent, ) +from arcade.gui.experimental.scroll_area import UIScrollArea from arcade.gui.property import ListProperty, Property, bind from arcade.gui.surface import Surface from arcade.gui.widgets import FocusMode, UIWidget @@ -174,6 +175,19 @@ def set_focus(self, widget: UIWidget | None | EllipsisType = ...): self.focused_widget.focused = False widget.focused = True self._focused_widget = widget + self._scroll_into_view(widget) + + @staticmethod + def _scroll_into_view(widget: UIWidget): + """Scroll all scroll areas containing the widget, so it becomes visible.""" + target = widget + parent = widget.parent + while isinstance(parent, UIWidget): + if isinstance(parent, UIScrollArea): + parent.scroll_to_widget(target) + # outer scroll areas only see this scroll area, not its content + target = parent + parent = parent.parent def focus_up(self): widget = self.focused_widget diff --git a/arcade/gui/experimental/scroll_area.py b/arcade/gui/experimental/scroll_area.py index 79c24389a..ba6605326 100644 --- a/arcade/gui/experimental/scroll_area.py +++ b/arcade/gui/experimental/scroll_area.py @@ -191,6 +191,8 @@ class UIScrollArea(UILayout): :class:`~arcade.gui.experimental.scroll_area.UIScrollBar` and, while the mouse hovers the widget, the keyboard (arrow keys, PageUp/PageDown, Home/End). + Inside a :class:`~arcade.gui.experimental.focus.UIFocusGroup`, the + scroll area also follows the focused widget, see :py:meth:`scroll_to_widget`. .. note:: Content containing labels may finish sizing only during the first @@ -407,6 +409,26 @@ def _clamp_scroll(self): scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface self.scroll_y = max(scroll_range, min(0.0, self.scroll_y)) + def scroll_to_widget(self, widget: UIWidget): + rect = widget.rect + # visible part of the canvas, in canvas coordinates + visible_top = self.surface.height + self.scroll_y + visible_bottom = visible_top - self.content_height + visible_left = -self.scroll_x + visible_right = visible_left + self.content_width + + if rect.top > visible_top: + self.scroll_y = rect.top - self.surface.height + elif rect.bottom < visible_bottom: + self.scroll_y = rect.bottom - self.surface.height + self.content_height + + if rect.left < visible_left: + self.scroll_x = -rect.left + elif rect.right > visible_right: + self.scroll_x = self.content_width - rect.right + + self._clamp_scroll() + def _scroll_by_key(self, symbol: int) -> bool: """Scroll on arrow keys, PageUp/PageDown, Home and End. diff --git a/tests/unit/gui/test_focus.py b/tests/unit/gui/test_focus.py index 7f8fbf598..e26c855cd 100644 --- a/tests/unit/gui/test_focus.py +++ b/tests/unit/gui/test_focus.py @@ -1,7 +1,8 @@ from pyglet.math import Vec2 -from arcade.gui import UIFlatButton +from arcade.gui import UIBoxLayout, UIFlatButton, UISpace from arcade.gui.experimental.focus import UIFocusGroup +from arcade.gui.experimental.scroll_area import UIScrollArea def test_focus_group_no_focus_set_by_default(ui): @@ -81,3 +82,42 @@ def test_nested_groups_dpad(ui): assert btn_1_2.focused is False assert btn_2_1.focused is False assert btn_2_2.focused is True + + +def test_focus_scrolls_scroll_area(ui): + group = ui.add(UIFocusGroup()) + scroll_area = group.add(UIScrollArea(width=300, height=300)) + box = scroll_area.add(UIBoxLayout()) + buttons = [box.add(UIFlatButton(width=100, height=50)) for _ in range(10)] + # second pass aligns the children to the resized canvas + ui.execute_layout() + ui.execute_layout() + group.detect_focusable_widgets() + + group.set_focus(buttons[-1]) + assert scroll_area.scroll_y == -200 + + group.focus_next() # wraps around to the first button + assert group.focused_widget is buttons[0] + assert scroll_area.scroll_y == 0 + + +def test_focus_scrolls_nested_scroll_areas(ui): + group = ui.add(UIFocusGroup()) + outer = group.add(UIScrollArea(width=300, height=300)) + outer_box = outer.add(UIBoxLayout()) + # spacer pushes the inner scroll area below the outer viewport + outer_box.add(UISpace(width=100, height=400)) + inner = outer_box.add(UIScrollArea(width=300, height=100)) + inner_box = inner.add(UIBoxLayout()) + buttons = [inner_box.add(UIFlatButton(width=100, height=50)) for _ in range(4)] + # nested canvases need one pass per level to settle + for _ in range(3): + ui.execute_layout() + group.detect_focusable_widgets() + + group.set_focus(buttons[-1]) + + # inner area shows its last 100px, outer area shows the inner area + assert inner.scroll_y == -100 + assert outer.scroll_y == -200 diff --git a/tests/unit/gui/test_uiscrollarea.py b/tests/unit/gui/test_uiscrollarea.py index ed84e3919..1389491d9 100644 --- a/tests/unit/gui/test_uiscrollarea.py +++ b/tests/unit/gui/test_uiscrollarea.py @@ -135,3 +135,64 @@ def test_scroll_speed_via_constructor(ui): default_area = UIScrollArea() assert default_area.scroll_speed == UIScrollArea.scroll_speed assert default_area.invert_scroll is UIScrollArea.invert_scroll + + +@pytest.fixture +def settled_scroll_setup(scroll_setup): + """scroll_setup after a second layout pass. + + The first pass resizes the canvas, children align to the new canvas only in the next pass. + Rows span from y=450..500 (row 0) down to y=0..50 (row 9), the viewport shows y=200..500. + """ + ui, scroll_area, box = scroll_setup + ui.execute_layout() + return scroll_setup + + +def test_scroll_to_widget_below(settled_scroll_setup): + ui, scroll_area, box = settled_scroll_setup + + scroll_area.scroll_to_widget(box.children[-1]) + + assert scroll_area.scroll_y == -200 + + +def test_scroll_to_widget_above(settled_scroll_setup): + ui, scroll_area, box = settled_scroll_setup + scroll_area.scroll_y = -200 + + scroll_area.scroll_to_widget(box.children[0]) + + assert scroll_area.scroll_y == 0 + + +def test_scroll_to_widget_scrolls_minimal_distance(settled_scroll_setup): + ui, scroll_area, box = settled_scroll_setup + + # row 6 spans y=150..200, just below the viewport + scroll_area.scroll_to_widget(box.children[6]) + + assert scroll_area.scroll_y == -50 + + +def test_scroll_to_visible_widget_does_not_scroll(settled_scroll_setup): + ui, scroll_area, box = settled_scroll_setup + + scroll_area.scroll_to_widget(box.children[2]) + + assert scroll_area.scroll_y == 0 + + +def test_scroll_to_widget_horizontal(ui): + scroll_area = ui.add(UIScrollArea(width=300, height=300)) + box = scroll_area.add(UIBoxLayout(vertical=False)) + for _ in range(10): + box.add(UIDummy(width=50, height=100)) + ui.execute_layout() + ui.execute_layout() + + scroll_area.scroll_to_widget(box.children[-1]) + assert scroll_area.scroll_x == -200 + + scroll_area.scroll_to_widget(box.children[0]) + assert scroll_area.scroll_x == 0