Skip to content

Commit a8630e5

Browse files
Add scrollbar support to UIDropdown (#2833)
* Add scrollbar support to UIDropdown The dropdown menu now wraps options in a UIScrollArea with a UIScrollBar, preventing the menu from extending beyond the window when there are many options. A new max_height parameter (default 200px) controls when scrolling activates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix overlay sizing by using size_hint=None The UIManager was overriding the overlay rect set by UIDropdown.do_layout because size_hint=(0, 0) caused it to be resized to 0x0. Using size_hint=None prevents the UIManager from touching the size. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add scroll options, tests, docs, and example updates - Add invert_scroll, scroll_speed, show_scroll_bar parameters to UIDropdown - Add 12 integration tests covering the full PR test plan - Update CHANGELOG, tutorial docs, and examples with scroll features - Examples now use many options to demonstrate scrolling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b95e97b commit a8630e5

File tree

6 files changed

+322
-18
lines changed

6 files changed

+322
-18
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
55

66
## Unreleased
77

8+
### New Features
9+
- GUI: `UIDropdown` now supports scrolling when options exceed the menu height. New parameters: `max_height`, `invert_scroll`, `scroll_speed`, and `show_scroll_bar`.
10+
811
### Breaking Change
9-
- Tilemap: Sprites of an object tile layer will now apply visibility of the object.
12+
- Tilemap: Sprites of an object tile layer will now apply visibility of the object.
1013

1114
## 4.0.0.dev3
1215

arcade/examples/gui/2_widgets.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,8 @@ def _show_interactive_widgets(self):
395395
dropdown_row.add(
396396
UIDropdown(
397397
default="Option 1",
398-
options=["Option 1", "Option 2", "Option 3"],
398+
options=[f"Option {i}" for i in range(1, 16)],
399+
show_scroll_bar=True,
399400
)
400401
)
401402

arcade/gui/widgets/dropdown.py

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,63 @@
88
from arcade.gui.events import UIControllerButtonPressEvent, UIOnChangeEvent, UIOnClickEvent
99
from arcade.gui.experimental import UIScrollArea
1010
from arcade.gui.experimental.focus import UIFocusMixin
11+
from arcade.gui.experimental.scroll_area import UIScrollBar
1112
from arcade.gui.ui_manager import UIManager
1213
from arcade.gui.widgets import UILayout, UIWidget
1314
from arcade.gui.widgets.buttons import UIFlatButton
1415
from arcade.gui.widgets.layout import UIBoxLayout
1516

1617

1718
class _UIDropdownOverlay(UIFocusMixin, UIBoxLayout):
18-
"""Represents the dropdown options overlay.
19+
"""Represents the dropdown options overlay with scroll support.
1920
20-
Currently only handles closing the overlay when clicked outside of the options.
21+
Contains a UIScrollArea with the option buttons and a UIScrollBar
22+
for navigating when options exceed the maximum height.
2123
"""
2224

23-
# TODO move also options logic to this class
25+
SCROLL_BAR_WIDTH = 15
26+
27+
def __init__(
28+
self,
29+
max_height: float = 200,
30+
invert_scroll: bool = False,
31+
scroll_speed: float = 15.0,
32+
show_scroll_bar: bool = False,
33+
):
34+
# Horizontal layout: [scroll_area | scroll_bar]
35+
# size_hint=None prevents UIManager from overriding the rect
36+
# that UIDropdown.do_layout explicitly sets.
37+
super().__init__(vertical=False, align="top", size_hint=None)
38+
self._max_height = max_height
39+
self._show_scroll_bar = show_scroll_bar
40+
41+
self._options_layout = UIBoxLayout(size_hint=(1, 0))
42+
self._scroll_area = UIScrollArea(
43+
width=100,
44+
height=100,
45+
canvas_size=(100, 100),
46+
size_hint=(1, 1),
47+
)
48+
self._scroll_area.invert_scroll = invert_scroll
49+
self._scroll_area.scroll_speed = scroll_speed
50+
self._scroll_area.add(self._options_layout)
51+
52+
super().add(self._scroll_area)
53+
54+
if show_scroll_bar:
55+
self._scroll_bar = UIScrollBar(self._scroll_area, vertical=True)
56+
self._scroll_bar.size_hint = (None, 1)
57+
self._scroll_bar.rect = self._scroll_bar.rect.resize(width=self.SCROLL_BAR_WIDTH)
58+
super().add(self._scroll_bar)
59+
60+
def add_option(self, widget: UIWidget) -> UIWidget:
61+
"""Add an option widget to the options layout."""
62+
return self._options_layout.add(widget)
63+
64+
def clear_options(self):
65+
"""Clear all options and reset scroll position."""
66+
self._options_layout.clear()
67+
self._scroll_area.scroll_y = 0
2468

2569
def show(self, manager: UIManager | UIScrollArea):
2670
manager.add(self, layer=UIManager.OVERLAY_LAYER)
@@ -67,6 +111,10 @@ def on_change(event: UIOnChangeEvent):
67111
height: Height of each of the option.
68112
default: The default value shown.
69113
options: The options displayed when the layout is clicked.
114+
max_height: Maximum height of the dropdown menu before scrolling is enabled.
115+
invert_scroll: Invert the scroll direction of the dropdown menu.
116+
scroll_speed: Speed of scrolling in the dropdown menu.
117+
show_scroll_bar: Show a scroll bar in the dropdown menu.
70118
primary_style: The style of the primary button.
71119
dropdown_style: The style of the buttons in the dropdown.
72120
active_style: The style of the dropdown button, which represents the active option.
@@ -120,6 +168,10 @@ def __init__(
120168
height: float = 30,
121169
default: str | None = None,
122170
options: list[str | None] | None = None,
171+
max_height: float = 200,
172+
invert_scroll: bool = False,
173+
scroll_speed: float = 15.0,
174+
show_scroll_bar: bool = False,
123175
primary_style=None,
124176
dropdown_style=None,
125177
active_style=None,
@@ -150,7 +202,12 @@ def __init__(
150202
)
151203
self._default_button.on_click = self._on_button_click # type: ignore
152204

153-
self._overlay = _UIDropdownOverlay()
205+
self._overlay = _UIDropdownOverlay(
206+
max_height=max_height,
207+
invert_scroll=invert_scroll,
208+
scroll_speed=scroll_speed,
209+
show_scroll_bar=show_scroll_bar,
210+
)
154211
self._update_options()
155212

156213
# add children after super class setup
@@ -176,16 +233,16 @@ def value(self, value: str | None):
176233

177234
def _update_options(self):
178235
# generate options
179-
self._overlay.clear()
236+
self._overlay.clear_options()
180237

181238
for option in self._options:
182239
if option is None: # None = UIDropdown.DIVIDER, required by pyright
183-
self._overlay.add(
240+
self._overlay.add_option(
184241
UIWidget(width=self.width, height=2).with_background(color=arcade.color.GRAY)
185242
)
186243
continue
187244
else:
188-
button = self._overlay.add(
245+
button = self._overlay.add_option(
189246
UIFlatButton(
190247
text=option,
191248
width=self.width,
@@ -225,13 +282,23 @@ def do_layout(self):
225282
but is required for the dropdown."""
226283
self._default_button.rect = self.rect
227284

228-
# resize layout to contain widgets
229-
overlay = self._overlay
230-
rect = overlay.rect
231-
if overlay.size_hint_min is not None:
232-
rect = rect.resize(*overlay.size_hint_min)
285+
# Calculate total options height
286+
total_h = 0
287+
for option in self._options:
288+
total_h += 2 if option is None else self.height
233289

234-
self._overlay.rect = rect.align_top(self.bottom - 2).align_left(self._default_button.left)
290+
# Cap at max_height
291+
overlay = self._overlay
292+
visible_h = min(total_h, overlay._max_height) if total_h > 0 else self.height
293+
scroll_bar_w = _UIDropdownOverlay.SCROLL_BAR_WIDTH if overlay._show_scroll_bar else 0
294+
overlay_w = self.width + scroll_bar_w
295+
296+
overlay.rect = (
297+
overlay.rect
298+
.resize(overlay_w, visible_h)
299+
.align_top(self.bottom - 2)
300+
.align_left(self._default_button.left)
301+
)
235302

236303
def on_change(self, event: UIOnChangeEvent):
237304
"""To be implemented by the user, triggered when the current selected value

doc/tutorials/menu/index.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,11 @@ Adding it to the widget layout.
295295
:caption: Adding dropdown to the layout
296296
:lines: 242
297297

298+
If a dropdown has many options, it will automatically scroll when the list
299+
exceeds the ``max_height`` (default 200px). You can also enable a visible
300+
scroll bar with ``show_scroll_bar=True``, control the scroll direction with
301+
``invert_scroll``, and adjust the scroll speed with ``scroll_speed``.
302+
298303
Adding a Slider
299304
~~~~~~~~~~~~~~~
300305

doc/tutorials/menu/menu_05.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,20 @@ def on_click_volume_button(event):
119119
"Volume Menu",
120120
"How do you like your volume?",
121121
"Enable Sound",
122-
["Play: Rock", "Play: Punk", "Play: Pop"],
122+
[
123+
"Play: Rock",
124+
"Play: Punk",
125+
"Play: Pop",
126+
"Play: Jazz",
127+
"Play: Blues",
128+
"Play: Classical",
129+
"Play: Country",
130+
"Play: Electronic",
131+
"Play: Hip Hop",
132+
"Play: Metal",
133+
"Play: R&B",
134+
"Play: Reggae",
135+
],
123136
"Adjust Volume",
124137
)
125138
self.manager.add(volume_menu, layer=1)
@@ -130,7 +143,20 @@ def on_click_options_button(event):
130143
"Funny Menu",
131144
"Too much fun here",
132145
"Fun?",
133-
["Make Fun", "Enjoy Fun", "Like Fun"],
146+
[
147+
"Make Fun",
148+
"Enjoy Fun",
149+
"Like Fun",
150+
"Share Fun",
151+
"Spread Fun",
152+
"Find Fun",
153+
"Create Fun",
154+
"Discover Fun",
155+
"Embrace Fun",
156+
"Celebrate Fun",
157+
"Inspire Fun",
158+
"Maximize Fun",
159+
],
134160
"Adjust Fun",
135161
)
136162
self.manager.add(options_menu, layer=1)
@@ -216,8 +242,13 @@ def __init__(
216242
toggle_group.add(toggle_label)
217243

218244
# Create dropdown with a specified default.
245+
# When many options are provided, the dropdown automatically scrolls.
219246
dropdown = arcade.gui.UIDropdown(
220-
default=dropdown_options[0], options=dropdown_options, height=20, width=250
247+
default=dropdown_options[0],
248+
options=dropdown_options,
249+
height=20,
250+
width=250,
251+
show_scroll_bar=True,
221252
)
222253

223254
slider_label = arcade.gui.UILabel(text=slider_label)

0 commit comments

Comments
 (0)