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)