diff --git a/CHANGELOG.md b/CHANGELOG.md index cc98725a11..a25b09b89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,11 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ## Unreleased +### New Features +- GUI: `UIDropdown` now supports scrolling when options exceed the menu height. New parameters: `max_height`, `invert_scroll`, `scroll_speed`, and `show_scroll_bar`. + ### Breaking Change -- Tilemap: Sprites of an object tile layer will now apply visibility of the object. +- Tilemap: Sprites of an object tile layer will now apply visibility of the object. ## 4.0.0.dev3 diff --git a/arcade/examples/gui/2_widgets.py b/arcade/examples/gui/2_widgets.py index 74223dc0c3..00706d1f77 100644 --- a/arcade/examples/gui/2_widgets.py +++ b/arcade/examples/gui/2_widgets.py @@ -395,7 +395,8 @@ def _show_interactive_widgets(self): dropdown_row.add( UIDropdown( default="Option 1", - options=["Option 1", "Option 2", "Option 3"], + options=[f"Option {i}" for i in range(1, 16)], + show_scroll_bar=True, ) ) diff --git a/arcade/gui/widgets/dropdown.py b/arcade/gui/widgets/dropdown.py index 43e88358a9..9cb59c2881 100644 --- a/arcade/gui/widgets/dropdown.py +++ b/arcade/gui/widgets/dropdown.py @@ -8,6 +8,7 @@ from arcade.gui.events import UIControllerButtonPressEvent, UIOnChangeEvent, UIOnClickEvent from arcade.gui.experimental import UIScrollArea from arcade.gui.experimental.focus import UIFocusMixin +from arcade.gui.experimental.scroll_area import UIScrollBar from arcade.gui.ui_manager import UIManager from arcade.gui.widgets import UILayout, UIWidget from arcade.gui.widgets.buttons import UIFlatButton @@ -15,12 +16,55 @@ class _UIDropdownOverlay(UIFocusMixin, UIBoxLayout): - """Represents the dropdown options overlay. + """Represents the dropdown options overlay with scroll support. - Currently only handles closing the overlay when clicked outside of the options. + Contains a UIScrollArea with the option buttons and a UIScrollBar + for navigating when options exceed the maximum height. """ - # TODO move also options logic to this class + SCROLL_BAR_WIDTH = 15 + + def __init__( + self, + max_height: float = 200, + invert_scroll: bool = False, + scroll_speed: float = 15.0, + show_scroll_bar: bool = False, + ): + # Horizontal layout: [scroll_area | scroll_bar] + # size_hint=None prevents UIManager from overriding the rect + # that UIDropdown.do_layout explicitly sets. + super().__init__(vertical=False, align="top", size_hint=None) + self._max_height = max_height + self._show_scroll_bar = show_scroll_bar + + self._options_layout = UIBoxLayout(size_hint=(1, 0)) + self._scroll_area = UIScrollArea( + width=100, + height=100, + canvas_size=(100, 100), + size_hint=(1, 1), + ) + self._scroll_area.invert_scroll = invert_scroll + self._scroll_area.scroll_speed = scroll_speed + self._scroll_area.add(self._options_layout) + + super().add(self._scroll_area) + + if show_scroll_bar: + self._scroll_bar = UIScrollBar(self._scroll_area, vertical=True) + self._scroll_bar.size_hint = (None, 1) + self._scroll_bar.rect = self._scroll_bar.rect.resize(width=self.SCROLL_BAR_WIDTH) + super().add(self._scroll_bar) + + def add_option(self, widget: UIWidget) -> UIWidget: + """Add an option widget to the options layout.""" + return self._options_layout.add(widget) + + def clear_options(self): + """Clear all options and reset scroll position.""" + self._options_layout.clear() + self._scroll_area.scroll_y = 0 def show(self, manager: UIManager | UIScrollArea): manager.add(self, layer=UIManager.OVERLAY_LAYER) @@ -67,6 +111,10 @@ def on_change(event: UIOnChangeEvent): height: Height of each of the option. default: The default value shown. options: The options displayed when the layout is clicked. + max_height: Maximum height of the dropdown menu before scrolling is enabled. + invert_scroll: Invert the scroll direction of the dropdown menu. + scroll_speed: Speed of scrolling in the dropdown menu. + show_scroll_bar: Show a scroll bar in the dropdown menu. primary_style: The style of the primary button. dropdown_style: The style of the buttons in the dropdown. active_style: The style of the dropdown button, which represents the active option. @@ -120,6 +168,10 @@ def __init__( height: float = 30, default: str | None = None, options: list[str | None] | None = None, + max_height: float = 200, + invert_scroll: bool = False, + scroll_speed: float = 15.0, + show_scroll_bar: bool = False, primary_style=None, dropdown_style=None, active_style=None, @@ -150,7 +202,12 @@ def __init__( ) self._default_button.on_click = self._on_button_click # type: ignore - self._overlay = _UIDropdownOverlay() + self._overlay = _UIDropdownOverlay( + max_height=max_height, + invert_scroll=invert_scroll, + scroll_speed=scroll_speed, + show_scroll_bar=show_scroll_bar, + ) self._update_options() # add children after super class setup @@ -176,16 +233,16 @@ def value(self, value: str | None): def _update_options(self): # generate options - self._overlay.clear() + self._overlay.clear_options() for option in self._options: if option is None: # None = UIDropdown.DIVIDER, required by pyright - self._overlay.add( + self._overlay.add_option( UIWidget(width=self.width, height=2).with_background(color=arcade.color.GRAY) ) continue else: - button = self._overlay.add( + button = self._overlay.add_option( UIFlatButton( text=option, width=self.width, @@ -225,13 +282,23 @@ def do_layout(self): but is required for the dropdown.""" self._default_button.rect = self.rect - # resize layout to contain widgets - overlay = self._overlay - rect = overlay.rect - if overlay.size_hint_min is not None: - rect = rect.resize(*overlay.size_hint_min) + # Calculate total options height + total_h = 0 + for option in self._options: + total_h += 2 if option is None else self.height - self._overlay.rect = rect.align_top(self.bottom - 2).align_left(self._default_button.left) + # Cap at max_height + overlay = self._overlay + visible_h = min(total_h, overlay._max_height) if total_h > 0 else self.height + scroll_bar_w = _UIDropdownOverlay.SCROLL_BAR_WIDTH if overlay._show_scroll_bar else 0 + overlay_w = self.width + scroll_bar_w + + overlay.rect = ( + overlay.rect + .resize(overlay_w, visible_h) + .align_top(self.bottom - 2) + .align_left(self._default_button.left) + ) def on_change(self, event: UIOnChangeEvent): """To be implemented by the user, triggered when the current selected value diff --git a/doc/tutorials/menu/index.rst b/doc/tutorials/menu/index.rst index f90a307d60..b8a36a64cc 100644 --- a/doc/tutorials/menu/index.rst +++ b/doc/tutorials/menu/index.rst @@ -295,6 +295,11 @@ Adding it to the widget layout. :caption: Adding dropdown to the layout :lines: 242 +If a dropdown has many options, it will automatically scroll when the list +exceeds the ``max_height`` (default 200px). You can also enable a visible +scroll bar with ``show_scroll_bar=True``, control the scroll direction with +``invert_scroll``, and adjust the scroll speed with ``scroll_speed``. + Adding a Slider ~~~~~~~~~~~~~~~ diff --git a/doc/tutorials/menu/menu_05.py b/doc/tutorials/menu/menu_05.py index d703ee0efc..a81890554b 100644 --- a/doc/tutorials/menu/menu_05.py +++ b/doc/tutorials/menu/menu_05.py @@ -119,7 +119,20 @@ def on_click_volume_button(event): "Volume Menu", "How do you like your volume?", "Enable Sound", - ["Play: Rock", "Play: Punk", "Play: Pop"], + [ + "Play: Rock", + "Play: Punk", + "Play: Pop", + "Play: Jazz", + "Play: Blues", + "Play: Classical", + "Play: Country", + "Play: Electronic", + "Play: Hip Hop", + "Play: Metal", + "Play: R&B", + "Play: Reggae", + ], "Adjust Volume", ) self.manager.add(volume_menu, layer=1) @@ -130,7 +143,20 @@ def on_click_options_button(event): "Funny Menu", "Too much fun here", "Fun?", - ["Make Fun", "Enjoy Fun", "Like Fun"], + [ + "Make Fun", + "Enjoy Fun", + "Like Fun", + "Share Fun", + "Spread Fun", + "Find Fun", + "Create Fun", + "Discover Fun", + "Embrace Fun", + "Celebrate Fun", + "Inspire Fun", + "Maximize Fun", + ], "Adjust Fun", ) self.manager.add(options_menu, layer=1) @@ -216,8 +242,13 @@ def __init__( toggle_group.add(toggle_label) # Create dropdown with a specified default. + # When many options are provided, the dropdown automatically scrolls. dropdown = arcade.gui.UIDropdown( - default=dropdown_options[0], options=dropdown_options, height=20, width=250 + default=dropdown_options[0], + options=dropdown_options, + height=20, + width=250, + show_scroll_bar=True, ) slider_label = arcade.gui.UILabel(text=slider_label) diff --git a/tests/unit/gui/test_uidropdown.py b/tests/unit/gui/test_uidropdown.py new file mode 100644 index 0000000000..22af8dd79c --- /dev/null +++ b/tests/unit/gui/test_uidropdown.py @@ -0,0 +1,197 @@ +from arcade.gui import UIAnchorLayout +from arcade.gui.widgets.dropdown import UIDropdown + +from . import record_ui_events + + +def test_dropdown_initial_value(ui): + dropdown = UIDropdown(default="Apple", options=["Apple", "Banana", "Cherry"]) + ui.add(UIAnchorLayout()).add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + assert dropdown.value == "Apple" + + +def test_dropdown_no_default_value(ui): + dropdown = UIDropdown(options=["Apple", "Banana", "Cherry"]) + ui.add(UIAnchorLayout()).add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + assert dropdown.value is None + + +def test_dropdown_select_option_via_click(ui): + dropdown = UIDropdown( + default="Apple", options=["Apple", "Banana", "Cherry"], width=200, height=30 + ) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Click the dropdown button to open the overlay + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + # The overlay should now be visible — click the second option ("Banana") + # Options are stacked below the button, each with height=30 + # First option starts at dropdown.bottom - 2, second option is 30px below that + option_x = dropdown.left + dropdown.width / 2 + option_y = dropdown.bottom - 2 - 15 # center of first option + second_option_y = option_y - 30 # center of second option + ui.click(option_x, second_option_y) + + assert dropdown.value == "Banana" + + +def test_dropdown_dispatches_on_change_event(ui): + dropdown = UIDropdown( + default="Apple", options=["Apple", "Banana", "Cherry"], width=200, height=30 + ) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + with record_ui_events(dropdown, "on_change") as events: + # Open and click first option (same as current — "Apple") + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + # Click "Banana" (second option) + option_x = dropdown.left + dropdown.width / 2 + second_option_y = dropdown.bottom - 2 - 15 - 30 + ui.click(option_x, second_option_y) + + assert len(events) == 1 + assert events[0].old_value == "Apple" + assert events[0].new_value == "Banana" + + +def test_dropdown_closes_on_click_outside(ui): + dropdown = UIDropdown( + default="Apple", options=["Apple", "Banana", "Cherry"], width=200, height=30 + ) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Open the dropdown + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + # Overlay should be shown (added to manager) + assert dropdown._overlay.parent is not None + + # Click far outside + ui.click(10, 10) + + # Overlay should be hidden (removed from manager) + assert dropdown._overlay.parent is None + + +def test_dropdown_overlay_height_capped_at_max_height(ui): + options = [f"Option {i}" for i in range(20)] + dropdown = UIDropdown(options=options, width=200, height=30, max_height=150) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Total options height would be 20 * 30 = 600, but should be capped at 150 + # Open the dropdown to trigger overlay layout + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + assert dropdown._overlay.height <= 150 + + +def test_dropdown_scroll_changes_visible_options(ui): + options = [f"Option {i}" for i in range(20)] + dropdown = UIDropdown(options=options, width=200, height=30, max_height=150) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Open the dropdown + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + scroll_area = dropdown._overlay._scroll_area + initial_scroll = scroll_area.scroll_y + + # Scroll within the overlay + overlay_cx, overlay_cy = dropdown._overlay.rect.center + ui.on_mouse_scroll(overlay_cx, overlay_cy, 0, 5) + + assert scroll_area.scroll_y != initial_scroll + + +def test_dropdown_with_scroll_bar(ui): + options = [f"Option {i}" for i in range(20)] + dropdown = UIDropdown(options=options, width=200, height=30, show_scroll_bar=True) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + assert dropdown._overlay._show_scroll_bar is True + + +def test_dropdown_without_scroll_bar(ui): + options = [f"Option {i}" for i in range(20)] + dropdown = UIDropdown(options=options, width=200, height=30, show_scroll_bar=False) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + assert dropdown._overlay._show_scroll_bar is False + + +def test_dropdown_value_setter_updates_button_text(ui): + dropdown = UIDropdown( + default="Apple", options=["Apple", "Banana", "Cherry"], width=200, height=30 + ) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + dropdown.value = "Cherry" + + assert dropdown.value == "Cherry" + assert dropdown._default_button.text == "Cherry" + + +def test_dropdown_few_options_no_scrolling(ui): + dropdown = UIDropdown(options=["Apple", "Banana", "Cherry"], width=200, height=30, max_height=200) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Open the dropdown + cx, cy = dropdown.rect.center + ui.click(cx, cy) + ui.execute_layout() + + # Total height is 3 * 30 = 90, well under max_height=200 + assert dropdown._overlay.height == 90 + + # Scrolling should have no effect (content fits) + scroll_area = dropdown._overlay._scroll_area + overlay_cx, overlay_cy = dropdown._overlay.rect.center + ui.on_mouse_scroll(overlay_cx, overlay_cy, 0, 5) + assert scroll_area.scroll_y == 0 + + +def test_dropdown_with_divider(ui): + options = ["Apple", UIDropdown.DIVIDER, "Banana", "Cherry"] + dropdown = UIDropdown(default="Apple", options=options, width=200, height=30) + anchor = ui.add(UIAnchorLayout()) + anchor.add(dropdown, anchor_x="center", anchor_y="center") + ui.execute_layout() + + # Should not crash; divider is a 2px separator + assert dropdown.value == "Apple" + # Options layout should have 4 children (3 buttons + 1 divider widget) + assert len(dropdown._overlay._options_layout.children) == 4