11from __future__ import annotations
22
3+ import warnings
34from collections .abc import Iterable
45from typing import TypeVar
56
910from arcade import XYWH
1011from arcade .gui .events import (
1112 UIEvent ,
13+ UIKeyPressEvent ,
1214 UIMouseDragEvent ,
1315 UIMouseEvent ,
1416 UIMouseMovementEvent ,
@@ -175,7 +177,26 @@ def do_render(self, surface: Surface):
175177class UIScrollArea (UILayout ):
176178 """A widget that can scroll its children.
177179
178- This widget is highly experimental and only provides a proof of concept.
180+ Children are laid out on an internal canvas, which resizes itself to fit
181+ all children (at least the size of the scroll area itself). The visible
182+ part of the canvas is controlled by :py:attr:`scroll_x` and
183+ :py:attr:`scroll_y` (both range from ``0`` at the start of the content
184+ to a negative value at the end).
185+
186+ For a typical scrollable list, add a container with ``size_hint=(1, 0)``
187+ (fill the width of the scroll area, grow to the natural content height)
188+ like ``UIBoxLayout``.
189+
190+ Scrolling is supported via mouse wheel, :class:`UIScrollBar` and, while
191+ the mouse hovers the widget, the keyboard
192+ (arrow keys, PageUp/PageDown, Home/End).
193+
194+ .. note::
195+ Content containing labels may finish sizing only during the first
196+ layout pass and render on the following frame. Single-frame capture
197+ pipelines (e.g. CI screenshots) should draw two frames.
198+
199+ This widget is experimental, the API might change.
179200
180201 Args:
181202 x: x position of the widget
@@ -186,18 +207,20 @@ class UIScrollArea(UILayout):
186207 size_hint: size hint of the widget
187208 size_hint_min: minimum size hint of the widget
188209 size_hint_max: maximum size hint of the widget
189- canvas_size: size of the canvas, which is scrollable
210+ canvas_size: deprecated, the canvas is sized automatically to fit
211+ all children
190212 overscroll_x: allow over scrolling in x direction (scroll past the end)
191213 overscroll_y: allow over scrolling in y direction (scroll past the end)
214+ scroll_speed: speed of scrolling in pixels per scroll event,
215+ defaults to :py:attr:`scroll_speed`
216+ invert_scroll: invert the scroll direction,
217+ defaults to :py:attr:`invert_scroll`
192218 **kwargs: passed to UIWidget
193219 """
194220
195221 scroll_x = Property [float ](default = 0.0 )
196222 scroll_y = Property [float ](default = 0.0 )
197223
198- scroll_speed = 1.8
199- invert_scroll = False
200-
201224 def __init__ (
202225 self ,
203226 * ,
@@ -209,11 +232,37 @@ def __init__(
209232 size_hint = None ,
210233 size_hint_min = None ,
211234 size_hint_max = None ,
212- canvas_size = ( 300 , 300 ) ,
235+ canvas_size = None ,
213236 overscroll_x = False ,
214237 overscroll_y = False ,
238+ scroll_speed : float = 15.0 ,
239+ invert_scroll : bool = False ,
215240 ** kwargs ,
216241 ):
242+ self .default_anchor_x = "left"
243+ self .default_anchor_y = "bottom"
244+ self .overscroll_x = overscroll_x
245+ self .overscroll_y = overscroll_y
246+ self .scroll_speed = scroll_speed
247+ self .invert_scroll = invert_scroll
248+ self ._hovering = False
249+
250+ if canvas_size is not None :
251+ warnings .warn (
252+ "canvas_size is deprecated, the canvas is sized automatically to fit all children" ,
253+ DeprecationWarning ,
254+ stacklevel = 2 ,
255+ )
256+ else :
257+ canvas_size = (max (int (width ), 1 ), max (int (height ), 1 ))
258+
259+ # The canvas has to match the window's pixel ratio,
260+ # otherwise content renders at reduced resolution on hi-DPI displays.
261+ self .surface = Surface (
262+ size = canvas_size ,
263+ pixel_ratio = arcade .get_window ().get_pixel_ratio (),
264+ )
265+
217266 super ().__init__ (
218267 x = x ,
219268 y = y ,
@@ -225,14 +274,6 @@ def __init__(
225274 size_hint_max = size_hint_max ,
226275 ** kwargs ,
227276 )
228- self .default_anchor_x = "left"
229- self .default_anchor_y = "bottom"
230- self .overscroll_x = overscroll_x
231- self .overscroll_y = overscroll_y
232-
233- self .surface = Surface (
234- size = canvas_size ,
235- )
236277
237278 bind (self , "scroll_x" , UIScrollArea .trigger_full_render )
238279 bind (self , "scroll_y" , UIScrollArea .trigger_full_render )
@@ -285,16 +326,20 @@ def do_layout(self):
285326 if new_rect != child .rect :
286327 child .rect = new_rect
287328
288- total_min_x = round (total_min_x )
289- total_min_y = round (total_min_y )
329+ # the canvas covers at least the visible area, so children which do not
330+ # fill the scroll area never leave the viewport partially uncovered
331+ # and scroll ranges never become negative
332+ total_min_x = max (round (total_min_x ), round (self .content_width ), 1 )
333+ total_min_y = max (round (total_min_y ), round (self .content_height ), 1 )
290334
291335 # resize surface to fit all children
292336 if self .surface .size != (total_min_x , total_min_y ):
293337 self .surface .resize (
294338 size = (total_min_x , total_min_y ), pixel_ratio = self .surface .pixel_ratio
295339 )
296- self .scroll_x = 0
297- self .scroll_y = 0
340+ # preserve the scroll position when content changes,
341+ # only clamp it into the new scroll range
342+ self ._clamp_scroll ()
298343
299344 def _do_render (self , surface : Surface , force = False ) -> bool :
300345 if not self .visible :
@@ -336,8 +381,58 @@ def _get_scroll_offset(self):
336381
337382 return self .scroll_x , - normal_pos_y - self .scroll_y
338383
384+ def _clamp_scroll (self ):
385+ """Clamp the scroll position into the valid scroll range.
386+
387+ Axes with overscroll enabled are not clamped.
388+ """
389+ if not self .overscroll_x :
390+ # clip scroll_x between 0 and -(self.surface.width - self.width)
391+ scroll_range = int (self .content_width - self .surface .width )
392+ scroll_range = min (0 , scroll_range ) # clip to 0 if content is smaller than surface
393+ self .scroll_x = max (scroll_range , min (0.0 , self .scroll_x ))
394+
395+ if not self .overscroll_y :
396+ # clip scroll_y between 0 and -(self.surface.height - self.height)
397+ scroll_range = int (self .content_height - self .surface .height )
398+ scroll_range = min (0 , scroll_range ) # clip to 0 if content is smaller than surface
399+ self .scroll_y = max (scroll_range , min (0.0 , self .scroll_y ))
400+
401+ def _scroll_by_key (self , symbol : int ) -> bool :
402+ """Scroll on arrow keys, PageUp/PageDown, Home and End.
403+
404+ Returns True if the key was handled.
405+ """
406+ v_scrollable = self .surface .height > self .content_height
407+ h_scrollable = self .surface .width > self .content_width
408+
409+ if symbol == arcade .key .UP and v_scrollable :
410+ self .scroll_y += self .scroll_speed
411+ elif symbol == arcade .key .DOWN and v_scrollable :
412+ self .scroll_y -= self .scroll_speed
413+ elif symbol == arcade .key .LEFT and h_scrollable :
414+ self .scroll_x += self .scroll_speed
415+ elif symbol == arcade .key .RIGHT and h_scrollable :
416+ self .scroll_x -= self .scroll_speed
417+ elif symbol == arcade .key .PAGEUP and v_scrollable :
418+ self .scroll_y += self .content_height
419+ elif symbol == arcade .key .PAGEDOWN and v_scrollable :
420+ self .scroll_y -= self .content_height
421+ elif symbol == arcade .key .HOME and v_scrollable :
422+ self .scroll_y = 0.0
423+ elif symbol == arcade .key .END and v_scrollable :
424+ self .scroll_y = float (min (0 , int (self .content_height - self .surface .height )))
425+ else :
426+ return False
427+
428+ self ._clamp_scroll ()
429+ return True
430+
339431 def on_event (self , event : UIEvent ) -> bool | None :
340432 """Handle scrolling of the widget."""
433+ if isinstance (event , UIMouseMovementEvent ):
434+ self ._hovering = self .rect .point_in_rect (event .pos )
435+
341436 if isinstance (event , UIMouseDragEvent ) and not self .rect .point_in_rect (event .pos ):
342437 return EVENT_UNHANDLED
343438
@@ -347,23 +442,13 @@ def on_event(self, event: UIEvent) -> bool | None:
347442 self .scroll_x -= - event .scroll_x * self .scroll_speed * invert
348443 self .scroll_y -= event .scroll_y * self .scroll_speed * invert
349444
350- # clip scrolling to canvas size
351- if not self .overscroll_x :
352- # clip scroll_x between 0 and -(self.surface.width - self.width)
353- scroll_range = int (self .content_width - self .surface .width )
354- scroll_range = min (0 , scroll_range ) # clip to 0 if content is smaller than surface
355- self .scroll_x = min (0 , self .scroll_x )
356- self .scroll_x = max (self .scroll_x , scroll_range )
357-
358- if not self .overscroll_y :
359- # clip scroll_y between 0 and -(self.surface.height - self.height)
360- scroll_range = int (self .content_height - self .surface .height )
361- scroll_range = min (0 , scroll_range ) # clip to 0 if content is smaller than surface
362- self .scroll_y = min (0 , self .scroll_y )
363- self .scroll_y = max (self .scroll_y , scroll_range )
364-
445+ self ._clamp_scroll ()
365446 return True
366447
448+ if isinstance (event , UIKeyPressEvent ) and self ._hovering :
449+ if self ._scroll_by_key (event .symbol ):
450+ return True
451+
367452 child_event = event
368453 if isinstance (event , UIMouseEvent ):
369454 if self .rect .point_in_rect (event .pos ):
0 commit comments