Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions arcade/gui/experimental/focus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions arcade/gui/experimental/scroll_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
42 changes: 41 additions & 1 deletion tests/unit/gui/test_focus.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
61 changes: 61 additions & 0 deletions tests/unit/gui/test_uiscrollarea.py
Original file line number Diff line number Diff line change
Expand Up @@ -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