Source code for arcade.gui.experimental.scroll_area
from __future__ import annotations
import warnings
from collections.abc import Iterable
from typing import TypeVar
from pyglet.event import EVENT_UNHANDLED
import arcade
from arcade import XYWH
from arcade.gui.events import (
UIEvent,
UIKeyPressEvent,
UIMouseDragEvent,
UIMouseEvent,
UIMouseMovementEvent,
UIMousePressEvent,
UIMouseReleaseEvent,
UIMouseScrollEvent,
)
from arcade.gui.property import Property, bind
from arcade.gui.surface import Surface
from arcade.gui.widgets import UIWidget
from arcade.gui.widgets.layout import UILayout
from arcade.types import LBWH
W = TypeVar("W", bound="UIWidget")
[docs]
class UIScrollBar(UIWidget):
"""Scroll bar for a UIScrollLayout.
Indicating the current view position of the scroll area.
Supports mouse dragging to scroll the content.
"""
_thumb_hover = Property(False)
_dragging = Property(False)
def __init__(self, scroll_area: UIScrollArea, vertical: bool = True):
size_hint = (0.05, 1) if vertical else (1, 0.05)
super().__init__(size_hint=size_hint)
self.scroll_area = scroll_area
self.with_background(color=arcade.color.LIGHT_GRAY)
self.with_border(color=arcade.uicolor.GRAY_CONCRETE)
self.vertical = vertical
bind(self, "_thumb_hover", UIScrollBar.trigger_render)
bind(self, "_dragging", UIScrollBar.trigger_render)
bind(scroll_area, "scroll_x", self.trigger_full_render, weak=True)
bind(scroll_area, "scroll_y", self.trigger_full_render, weak=True)
bind(scroll_area, "rect", self.trigger_full_render, weak=True)
[docs]
def on_event(self, event: UIEvent) -> bool | None:
# check if we are scrollable
if not self._scrollable():
return EVENT_UNHANDLED
# detect if event is mouse down and inside the scroll thumb
# if so, start dragging the thumb
thumb_rect_relative = self._thumb_rect()
thumb_rect = thumb_rect_relative.move(*self.rect.bottom_left)
if isinstance(event, UIMouseMovementEvent):
self._thumb_hover = thumb_rect.point_in_rect(event.pos)
if isinstance(event, UIMousePressEvent) and thumb_rect.point_in_rect(event.pos):
self._dragging = True
return True
# detect if event is mouse drag and thumb is being dragged
# if so, update the scroll position
if isinstance(event, UIMouseDragEvent) and self._dragging:
sx, sy = event.pos - self.rect.bottom_left
sx -= self._scroll_bar_size() / 2
sy -= self._scroll_bar_size() / 2
scroll_area = self.scroll_area
if self.vertical:
available_track_size = self.content_height - self._scroll_bar_size()
target_progress = 1 - sy / available_track_size
target_progress = max(0, min(1, target_progress))
scroll_range = scroll_area.surface.height - scroll_area.content_height
scroll_area.scroll_y = -target_progress * scroll_range
else:
available_track_size = self.content_width - self._scroll_bar_size()
target_progress = sx / available_track_size
target_progress = max(0, min(1, target_progress))
scroll_range = scroll_area.surface.width - scroll_area.content_width
scroll_area.scroll_x = -target_progress * scroll_range
return True
# detect if event is mouse up and thumb is being dragged
# if so, stop dragging the thumb
if isinstance(event, UIMouseReleaseEvent) and self._dragging:
self._dragging = False
return True
return EVENT_UNHANDLED
def _scroll_bar_size(self):
# based on: https://stackoverflow.com/a/16367035
content_size = (
self.scroll_area.surface.height if self.vertical else self.scroll_area.surface.width
)
view_size = (
self.scroll_area.content_height if self.vertical else self.scroll_area.content_width
)
ratio = view_size / content_size
scoll_range = self.content_height if self.vertical else self.content_width
return scoll_range * ratio
def _scrollable(self):
return (
self.scroll_area.surface.height - self.scroll_area.content_height
if self.vertical
else self.scroll_area.surface.width - self.scroll_area.content_width
) > 0
def _thumb_rect(self):
"""Calculate the rect of the thumb."""
scroll_area = self.scroll_area
scroll_value = scroll_area.scroll_y if self.vertical else scroll_area.scroll_x
scroll_range = (
scroll_area.surface.height - scroll_area.content_height
if self.vertical
else scroll_area.surface.width - scroll_area.content_width
)
if not self._scrollable():
# content is smaller than the scroll area, full size thumb
return LBWH(0, 0, self.content_width, self.content_height)
scroll_progress = -scroll_value / scroll_range
content_size = self.content_height if self.vertical else self.content_width
available_track_size = content_size - self._scroll_bar_size()
if self.vertical:
scroll_bar_y = self._scroll_bar_size() / 2 + available_track_size * (
1 - scroll_progress
)
scroll_bar_x = self.content_width / 2
return XYWH(scroll_bar_x, scroll_bar_y, self.content_width, self._scroll_bar_size())
else:
scroll_bar_x = self._scroll_bar_size() / 2 + available_track_size * scroll_progress
scroll_bar_y = self.content_height / 2
return XYWH(scroll_bar_x, scroll_bar_y, self._scroll_bar_size(), self.content_height)
[docs]
def do_render(self, surface: Surface):
"""Render the scroll bar."""
self.prepare_render(surface)
if self._dragging:
thumb_color = arcade.uicolor.DARK_BLUE_WET_ASPHALT
elif self._thumb_hover:
thumb_color = arcade.uicolor.GRAY_CONCRETE
else:
thumb_color = arcade.uicolor.GRAY_ASBESTOS
# draw the thumb
arcade.draw_rect_filled(
rect=self._thumb_rect(),
color=thumb_color,
)
[docs]
class UIScrollArea(UILayout):
"""A widget that can scroll its children.
Children are laid out on an internal canvas, which resizes itself to fit
all children (at least the size of the scroll area itself). The visible
part of the canvas is controlled by :py:attr:`scroll_x` and
:py:attr:`scroll_y` (both range from ``0`` at the start of the content
to a negative value at the end).
For a typical scrollable list, add a container with ``size_hint=(1, 0)``
(fill the width of the scroll area, grow to the natural content height)
like ``UIBoxLayout``.
Scrolling is supported via mouse wheel, :class:`UIScrollBar` and, while
the mouse hovers the widget, the keyboard
(arrow keys, PageUp/PageDown, Home/End).
.. note::
Content containing labels may finish sizing only during the first
layout pass and render on the following frame. Single-frame capture
pipelines (e.g. CI screenshots) should draw two frames.
This widget is experimental, the API might change.
Args:
x: x position of the widget
y: y position of the widget
width: width of the widget
height: height of the widget
children: children of the widget
size_hint: size hint of the widget
size_hint_min: minimum size hint of the widget
size_hint_max: maximum size hint of the widget
canvas_size: deprecated, the canvas is sized automatically to fit
all children
overscroll_x: allow over scrolling in x direction (scroll past the end)
overscroll_y: allow over scrolling in y direction (scroll past the end)
scroll_speed: speed of scrolling in pixels per scroll event,
defaults to :py:attr:`scroll_speed`
invert_scroll: invert the scroll direction,
defaults to :py:attr:`invert_scroll`
**kwargs: passed to UIWidget
"""
scroll_x = Property[float](default=0.0)
scroll_y = Property[float](default=0.0)
scroll_speed: float = 15.0
"""Default scroll speed in pixels per scroll event."""
invert_scroll: bool = False
"""Default for inverting the scroll direction."""
def __init__(
self,
*,
x: float = 0,
y: float = 0,
width: float = 300,
height: float = 300,
children: Iterable[UIWidget] = tuple(),
size_hint=None,
size_hint_min=None,
size_hint_max=None,
canvas_size=None,
overscroll_x=False,
overscroll_y=False,
scroll_speed: float | None = None,
invert_scroll: bool | None = None,
**kwargs,
):
self.default_anchor_x = "left"
self.default_anchor_y = "bottom"
self.overscroll_x = overscroll_x
self.overscroll_y = overscroll_y
# None falls back to the class attribute defaults
if scroll_speed is not None:
self.scroll_speed = scroll_speed
if invert_scroll is not None:
self.invert_scroll = invert_scroll
self._hovering = False
if canvas_size is not None:
warnings.warn(
"canvas_size is deprecated, the canvas is sized automatically to fit all children",
DeprecationWarning,
stacklevel=2,
)
else:
canvas_size = (max(int(width), 1), max(int(height), 1))
# The canvas has to match the window's pixel ratio,
# otherwise content renders at reduced resolution on hi-DPI displays.
self.surface = Surface(
size=canvas_size,
pixel_ratio=arcade.get_window().get_pixel_ratio(),
)
super().__init__(
x=x,
y=y,
width=width,
height=height,
children=children,
size_hint=size_hint,
size_hint_min=size_hint_min,
size_hint_max=size_hint_max,
**kwargs,
)
bind(self, "scroll_x", UIScrollArea.trigger_full_render)
bind(self, "scroll_y", UIScrollArea.trigger_full_render)
[docs]
def add(self, child: W, **kwargs) -> W:
"""Add a child to the widget."""
super().add(child, **kwargs)
self.trigger_full_render()
return child
[docs]
def remove(self, child: UIWidget):
"""Remove a child from the widget."""
super().remove(child)
self.trigger_full_render()
[docs]
def do_layout(self):
"""Layout the children of the widget."""
total_min_x = 0
total_min_y = 0
for child in self.children:
new_rect = child.rect
# apply sizehint
shw, shh = child.size_hint or (None, None)
# default min_size to be at least 1 for w and h, required by surface
shw_min, shh_min = child.size_hint_min or (1, 1)
shw_max, shh_max = child.size_hint_max or (None, None)
if shw is not None:
new_width = shw * self.content_width
new_width = max(shw_min or 1, new_width)
if shw_max is not None:
new_width = min(shw_max, new_width)
new_rect = new_rect.resize(width=new_width)
if shh is not None:
new_height = shh * self.content_height
new_height = max(shh_min or 1, new_height)
if shh_max is not None:
new_height = min(shh_max, new_height)
new_rect = new_rect.resize(height=new_height)
new_rect = new_rect.align_top(self.surface.height).align_left(0)
total_min_x = max(total_min_x, new_rect.width)
total_min_y = max(total_min_y, new_rect.height)
if new_rect != child.rect:
child.rect = new_rect
# the canvas covers at least the visible area, so children which do not
# fill the scroll area never leave the viewport partially uncovered
# and scroll ranges never become negative
total_min_x = max(round(total_min_x), round(self.content_width), 1)
total_min_y = max(round(total_min_y), round(self.content_height), 1)
# resize surface to fit all children
if self.surface.size != (total_min_x, total_min_y):
self.surface.resize(
size=(total_min_x, total_min_y), pixel_ratio=self.surface.pixel_ratio
)
# preserve the scroll position when content changes,
# only clamp it into the new scroll range
self._clamp_scroll()
def _do_render(self, surface: Surface, force=False) -> bool:
if not self.visible:
return False
should_render = force or self._requires_render
rendered = False
with self.surface.activate():
if should_render:
self.surface.clear()
if self.visible:
for child in self.children:
rendered |= child._do_render(self.surface, should_render)
if should_render or rendered:
rendered = True
self.do_render_base(surface)
self.do_render(surface)
self._rendered = True
self._requires_render = False
return rendered
[docs]
def do_render(self, surface: Surface):
"""Renders the scolled surface into the given surface."""
self.prepare_render(surface)
offset_x, offset_y = self._get_scroll_offset()
# position surface and draw visible area
self.surface.position = offset_x, offset_y
self.surface.draw(LBWH(-offset_x, -offset_y, self.content_width, self.content_height))
def _get_scroll_offset(self):
"""calculates the scroll offset for the surface position,
also used for calculating mouse event offset."""
normal_pos_y = self.surface.height - self.content_height
return self.scroll_x, -normal_pos_y - self.scroll_y
def _clamp_scroll(self):
"""Clamp the scroll position into the valid scroll range.
Axes with overscroll enabled are not clamped.
"""
if not self.overscroll_x:
# clip scroll_x between 0 and -(self.surface.width - self.width)
scroll_range = int(self.content_width - self.surface.width)
scroll_range = min(0, scroll_range) # clip to 0 if content is smaller than surface
self.scroll_x = max(scroll_range, min(0.0, self.scroll_x))
if not self.overscroll_y:
# clip scroll_y between 0 and -(self.surface.height - self.height)
scroll_range = int(self.content_height - self.surface.height)
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_by_key(self, symbol: int) -> bool:
"""Scroll on arrow keys, PageUp/PageDown, Home and End.
Returns True if the key was handled.
"""
v_scrollable = self.surface.height > self.content_height
h_scrollable = self.surface.width > self.content_width
if symbol == arcade.key.UP and v_scrollable:
self.scroll_y += self.scroll_speed
elif symbol == arcade.key.DOWN and v_scrollable:
self.scroll_y -= self.scroll_speed
elif symbol == arcade.key.LEFT and h_scrollable:
self.scroll_x += self.scroll_speed
elif symbol == arcade.key.RIGHT and h_scrollable:
self.scroll_x -= self.scroll_speed
elif symbol == arcade.key.PAGEUP and v_scrollable:
self.scroll_y += self.content_height
elif symbol == arcade.key.PAGEDOWN and v_scrollable:
self.scroll_y -= self.content_height
elif symbol == arcade.key.HOME and v_scrollable:
self.scroll_y = 0.0
elif symbol == arcade.key.END and v_scrollable:
self.scroll_y = float(min(0, int(self.content_height - self.surface.height)))
else:
return False
self._clamp_scroll()
return True
[docs]
def on_event(self, event: UIEvent) -> bool | None:
"""Handle scrolling of the widget."""
if isinstance(event, UIMouseMovementEvent):
self._hovering = self.rect.point_in_rect(event.pos)
if isinstance(event, UIMouseDragEvent) and not self.rect.point_in_rect(event.pos):
return EVENT_UNHANDLED
if isinstance(event, UIMouseScrollEvent) and self.rect.point_in_rect(event.pos):
invert = -1 if self.invert_scroll else 1
self.scroll_x -= -event.scroll_x * self.scroll_speed * invert
self.scroll_y -= event.scroll_y * self.scroll_speed * invert
self._clamp_scroll()
return True
if isinstance(event, UIKeyPressEvent) and self._hovering:
if self._scroll_by_key(event.symbol):
return True
child_event = event
if isinstance(event, UIMouseEvent):
if self.rect.point_in_rect(event.pos):
# create a new event with the position relative to the child
off_x, off_y = self._get_scroll_offset()
child_event = type(event)(**event.__dict__) # type: ignore
child_event.x = int(event.x - self.left - off_x)
child_event.y = int(event.y - self.bottom - off_y)
else:
# event is outside the scroll area, do not pass it to the children
return EVENT_UNHANDLED
return super().on_event(child_event)