Source code for arcade.gui.experimental.scroll_area
from __future__ import annotations
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,
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", self.trigger_render)
bind(self, "_dragging", self.trigger_render)
bind(scroll_area, "scroll_x", self.trigger_full_render)
bind(scroll_area, "scroll_y", self.trigger_full_render)
bind(scroll_area, "content_height", self.trigger_full_render)
bind(scroll_area, "content_width", self.trigger_full_render)
[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.
This widget is highly experimental and only provides a proof of concept.
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: size of the canvas, which is scrollable
overscroll_x: allow over scrolling in x direction (scroll past the end)
overscroll_y: allow over scrolling in y direction (scroll past the end)
**kwargs: passed to UIWidget
"""
scroll_x = Property[float](default=0.0)
scroll_y = Property[float](default=0.0)
scroll_speed = 1.8
invert_scroll = False
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=(300, 300),
overscroll_x=False,
overscroll_y=False,
**kwargs,
):
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,
)
self.default_anchor_x = "left"
self.default_anchor_y = "bottom"
self.overscroll_x = overscroll_x
self.overscroll_y = overscroll_y
self.surface = Surface(
size=canvas_size,
)
bind(self, "scroll_x", self.trigger_full_render)
bind(self, "scroll_y", self.trigger_full_render)
[docs]
def add(self, child: W, **kwargs) -> W:
"""Add a child to the widget."""
if self._children:
raise ValueError("UIScrollArea can only have one child")
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
total_min_x = round(total_min_x)
total_min_y = round(total_min_y)
# 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
)
self.scroll_x = 0
self.scroll_y = 0
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
[docs]
def on_event(self, event: UIEvent) -> bool | None:
"""Handle scrolling of the widget."""
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
# clip scrolling to canvas size
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 = min(0, self.scroll_x)
self.scroll_x = max(self.scroll_x, scroll_range)
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 = min(0, self.scroll_y)
self.scroll_y = max(self.scroll_y, scroll_range)
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)