Source code for pygame_menu.widgets.widget.scrollbar
"""
pygame-menu
https://github.com/ppizarror/pygame-menu
SCROLLBAR
ScrollBar class, manage the selection in a range of values.
License:
-------------------------------------------------------------------------------
The MIT License (MIT)
Copyright 2017-2021 Pablo Pizarro R. @ppizarror
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-------------------------------------------------------------------------------
"""
__all__ = ['ScrollBar']
import pygame
from pygame_menu.locals import ORIENTATION_VERTICAL, ORIENTATION_HORIZONTAL, \
POSITION_NORTHWEST, FINGERMOTION, FINGERUP, FINGERDOWN
from pygame_menu.utils import make_surface, assert_orientation, \
mouse_motion_current_mouse_position, assert_color, get_finger_pos
from pygame_menu.widgets.core import Widget
from pygame_menu._types import Optional, List, VectorIntType, ColorType, Literal, \
Tuple2IntType, CallbackType, NumberInstance, ColorInputType, NumberType, \
EventVectorType, VectorInstance
# noinspection PyMissingOrEmptyDocstring
[docs]class ScrollBar(Widget):
"""
A scroll bar include 3 separate controls: a slider, scroll arrows, and a
page control:
a. The slider provides a way to quickly go to any part of the document.
b. The scroll arrows are push buttons which can be used to accurately
navigate to a particular place in a document.
c. The page control is the area over which the slider is dragged (the
scroll bar's background). Clicking here moves the scroll bar towards
the click by one page.
.. note::
ScrollBar only accepts translation transformation.
:param length: Length of the page control
:param values_range: Min and max values
:param scrollbar_id: Bar identifier
:param orientation: Bar orientation (horizontal or vertical). See :py:mod:`pygame_menu.locals`
:param slider_pad: Space between slider and page control (px)
:param slider_color: Color of the slider
:param page_ctrl_thick: Page control thickness
:param page_ctrl_color: Page control color
:param onchange: Callback when pressing and moving the scroll
"""
_clicked: bool
_last_mouse_pos: Tuple2IntType
_orientation: Literal[0, 1]
_page_ctrl_color: ColorType
_page_ctrl_length: NumberType
_page_ctrl_thick: int
_page_step: NumberType
_shadow: bool
_shadow_color: ColorType
_shadow_offset: NumberType
_shadow_position: str
_shadow_tuple: Tuple2IntType
_single_step: NumberType
_slider_color: ColorType
_slider_hover_color: ColorType
_slider_pad: int
_slider_position: int
_slider_rect: Optional['pygame.Rect']
_values_range: List[NumberType]
scrolling: bool
def __init__(
self,
length: NumberType,
values_range: VectorIntType,
scrollbar_id: str = '',
orientation: str = ORIENTATION_HORIZONTAL,
slider_pad: NumberType = 0,
slider_color: ColorInputType = (200, 200, 200),
slider_hover_color: ColorInputType = (180, 180, 180),
page_ctrl_thick: int = 20,
page_ctrl_color: ColorInputType = (235, 235, 235),
onchange: CallbackType = None,
*args,
**kwargs
) -> None:
assert isinstance(length, NumberInstance)
assert isinstance(values_range, VectorInstance)
assert values_range[1] > values_range[0], 'minimum value first is expected'
assert isinstance(slider_pad, NumberInstance)
assert isinstance(page_ctrl_thick, int)
assert page_ctrl_thick - 2 * slider_pad >= 2, 'slider shall be visible'
page_ctrl_color = assert_color(page_ctrl_color)
slider_color = assert_color(slider_color)
slider_hover_color = assert_color(slider_hover_color)
super(ScrollBar, self).__init__(
widget_id=scrollbar_id,
onchange=onchange,
args=args,
kwargs=kwargs
)
self._check_mouseleave_call_render = True
self._clicked = False
self._last_mouse_pos = (-1, -1)
self._mouseover_check_rect = lambda: self.get_slider_rect()
self._orientation = 0 # 0: horizontal, 1: vertical
self._values_range = list(values_range)
# Page control
self._page_ctrl_color = page_ctrl_color
self._page_ctrl_length = length
self._page_ctrl_thick = page_ctrl_thick
# Slider
self._slider_color = slider_color
self._slider_hover_color = slider_hover_color
self._slider_pad = slider_pad
self._slider_position = 0
self._slider_rect = None
# Shadow
self._shadow = False
self._shadow_color = (0, 0, 0)
self._shadow_offset = 2.0
self._shadow_position = POSITION_NORTHWEST
self._shadow_tuple = (0, 0) # (x px offset, y px offset)
# Page step
self._page_step = 0
self._single_step = 20
if values_range[1] - values_range[0] > length:
self.set_page_step(length)
else:
self.set_page_step((values_range[1] - values_range[0]) / 5.0) # Arbitrary
self.set_orientation(orientation)
# Configure public's
self.is_scrollable = True
self.is_selectable = False
self.scrolling = False
def _apply_font(self) -> None:
pass
def set_padding(self, *args, **kwargs) -> 'ScrollBar':
return self
def scale(self, *args, **kwargs) -> 'ScrollBar':
return self
def resize(self, *args, **kwargs) -> 'ScrollBar':
return self
def set_max_width(self, *args, **kwargs) -> 'ScrollBar':
return self
def set_max_height(self, *args, **kwargs) -> 'ScrollBar':
return self
def rotate(self, *args, **kwargs) -> 'ScrollBar':
return self
def flip(self, *args, **kwargs) -> 'ScrollBar':
return self
def _apply_size_changes(self) -> None:
"""
Apply scrollbar changes.
:return: None
"""
opp_orientation = 1 if self._orientation == 0 else 0 # Opposite of orientation
dims = ('width', 'height')
setattr(self._rect, dims[self._orientation], int(self._page_ctrl_length))
setattr(self._rect, dims[opp_orientation], self._page_ctrl_thick)
self._slider_rect = pygame.Rect(0, 0, int(self._rect.width), int(self._rect.height))
setattr(self._slider_rect, dims[self._orientation], int(self._page_step))
setattr(self._slider_rect, dims[opp_orientation], self._page_ctrl_thick)
# Update slider position according to the current one
pos = ('x', 'y')
setattr(self._slider_rect, pos[self._orientation], int(self._slider_position))
self._slider_rect = self._slider_rect.inflate(-2 * self._slider_pad,
-2 * self._slider_pad)
[docs] def set_shadow(
self,
enabled: bool = True,
color: Optional[ColorInputType] = None,
position: Optional[str] = None,
offset: int = 2
) -> 'ScrollBar':
"""
Set the scrollbars shadow.
.. note::
See :py:mod:`pygame_menu.locals` for valid ``position`` values.
:param enabled: Shadow is enabled or not
:param color: Shadow color
:param position: Shadow position
:param offset: Shadow offset
:return: Self reference
"""
super(ScrollBar, self).set_font_shadow(enabled, color, position, offset)
# Store shadow from font
self._shadow = self._font_shadow
self._shadow_color = self._font_shadow_color
self._shadow_offset = self._font_shadow_offset
self._shadow_position = self._font_shadow_position
self._shadow_tuple = self._font_shadow_tuple
# Disable font
self._font_shadow = False
return self
def _draw(self, surface: 'pygame.Surface') -> None:
surface.blit(self._surface, self._rect.topleft)
[docs] def get_minimum(self) -> int:
"""
Return the smallest acceptable value.
:return: Smallest acceptable value
"""
return int(self._values_range[0])
[docs] def get_maximum(self) -> int:
"""
Return the greatest acceptable value.
:return: Greatest acceptable value
"""
return int(self._values_range[1])
[docs] def get_minmax(self) -> Tuple2IntType:
"""
Return the min and max acceptable tuple values.
:return: Min, Max tuple
"""
return self.get_minimum(), self.get_maximum()
[docs] def get_orientation(self) -> str:
"""
Return the scrollbar orientation (pygame-menu locals).
:return: Scrollbar orientation
"""
if self._orientation == 0:
return ORIENTATION_HORIZONTAL
else:
return ORIENTATION_VERTICAL
[docs] def get_page_step(self) -> int:
"""
Return amount that the value changes by when the user
click on the page control surface.
:return: Page step
"""
p_step = self._page_step * (self._values_range[1] -
self._values_range[0]) / self._page_ctrl_length
return int(p_step)
[docs] def get_value_percentage(self) -> float:
"""
Return the value but in percentage between ``0`` (minimum value) and ``1`` (maximum value).
:return: Value as percentage
"""
v_min, v_max = self.get_minmax()
value = self.get_value()
return round((value - v_min) / (v_max - v_min), 3)
[docs] def get_value(self) -> int:
"""
Return the value according to the slider position.
:return: Position in px
"""
value = self._values_range[0] + self._slider_position * \
(self._values_range[1] -
self._values_range[0]) / (self._page_ctrl_length - self._page_step)
# Correction due to value scaling
value = max(self._values_range[0], value)
value = min(self._values_range[1], value)
return int(value)
def _render(self) -> Optional[bool]:
width, height = self._rect.width + self._rect_size_delta[0], \
self._rect.height + self._rect_size_delta[1]
if not self._render_hash_changed(
width, height, self._slider_rect.x, self._slider_rect.y,
self.readonly, self._slider_rect.width, self._slider_rect.height,
self.scrolling, self._mouseover, self._clicked):
return True
self._surface = make_surface(width, height)
self._surface.fill(self._page_ctrl_color)
# Render slider
slider_color = self._slider_color if not self.readonly else self._font_readonly_color
mouse_hover = (self.scrolling and self._clicked) or self._mouseover
slider_color = self._slider_hover_color if mouse_hover else slider_color
if self._shadow:
lit_rect = pygame.Rect(self._slider_rect)
slider_rect = lit_rect.inflate(-self._shadow_offset * 2, -self._shadow_offset * 2)
shadow_rect = lit_rect.inflate(-self._shadow_offset, -self._shadow_offset)
shadow_rect = shadow_rect.move(int(self._shadow_tuple[0] / 2),
int(self._shadow_tuple[1] / 2))
pygame.draw.rect(self._surface, self._font_selected_color, lit_rect)
pygame.draw.rect(self._surface, self._shadow_color, shadow_rect)
pygame.draw.rect(self._surface, slider_color, slider_rect)
else:
pygame.draw.rect(self._surface, slider_color, self._slider_rect)
def _scroll(self, rect: 'pygame.Rect', pixels: NumberType) -> bool:
"""
Moves the slider based on mouse events relative to change along axis.
The slider travel is limited to page control length.
:param rect: Precomputed rect
:param pixels: Number of pixels to scroll
:return: ``True`` is scroll position has changed
"""
assert isinstance(pixels, NumberInstance)
if not pixels:
return False
axis = self._orientation
space_before = rect.topleft[axis] \
- self._slider_rect.move(*rect.topleft).topleft[axis] \
+ self._slider_pad
move = max(round(pixels), space_before)
space_after = rect.bottomright[axis] \
- self._slider_rect.move(*rect.topleft).bottomright[axis] \
- self._slider_pad
move = min(move, space_after)
if not move:
return False
move_pos = [0, 0]
move_pos[axis] = move
self._slider_rect.move_ip(*move_pos)
self._slider_position += move
return True
[docs] def set_length(self, value: NumberType) -> None:
"""
Set the length of the page control area.
:param value: Length of the area
:return: None
"""
assert isinstance(value, NumberInstance)
assert 0 < value
self._page_ctrl_length = value
self._slider_position = min(self._slider_position,
self._page_ctrl_length - self._page_step)
self._apply_size_changes()
[docs] def get_thickness(self) -> int:
"""
Return the thickness of the bar.
:return: Thickness in px
"""
return self._page_ctrl_thick
[docs] def hide(self) -> 'ScrollBar':
if self._mouseover:
self._mouseover = False
self.mouseleave(mouse_motion_current_mouse_position())
self._visible = False
return self
[docs] def set_maximum(self, value: NumberType) -> None:
"""
Set the greatest acceptable value.
:param value: Maximum value
:return: None
"""
assert isinstance(value, NumberInstance)
assert value > self._values_range[0], \
'maximum value shall greater than {}'.format(self._values_range[0])
self._values_range[1] = value
[docs] def set_minimum(self, value: NumberType) -> None:
"""
Set the smallest acceptable value.
:param value: Minimum value
:return: None
"""
assert isinstance(value, NumberInstance)
assert 0 <= value < self._values_range[1], \
'minimum value shall lower than {}'.format(self._values_range[1])
self._values_range[0] = value
[docs] def set_orientation(self, orientation: str) -> None:
"""
Set the scroll bar orientation to vertical or horizontal.
.. note::
See :py:mod:`pygame_menu.locals` for valid ``orientation`` values.
:param orientation: Widget orientation
:return: None
"""
assert_orientation(orientation)
if orientation == ORIENTATION_HORIZONTAL:
self._orientation = 0
elif orientation == ORIENTATION_VERTICAL:
self._orientation = 1
self._apply_size_changes()
[docs] def set_page_step(self, value: NumberType) -> None:
"""
Set the amount that the value changes by when the user click on the
page control surface.
.. note::
The length of the slider is related to this value, and typically
represents the proportion of the document area shown in a scrolling view.
:param value: Page step
:return: None
"""
assert isinstance(value, NumberInstance)
assert 0 < value, 'page step shall be > 0'
# Slider length shall represent the same ratio
self._page_step = self._page_ctrl_length * value / \
(self._values_range[1] - self._values_range[0])
if self._single_step >= self._page_step:
self._single_step = self._page_step // 2 # Arbitrary to be lower than page step
self._apply_size_changes()
[docs] def set_value(self, position_value: NumberType) -> None:
"""
Set the position of the scrollbar.
:param position_value: Position
:return: None
"""
assert isinstance(position_value, NumberInstance)
assert self._values_range[0] <= position_value <= self._values_range[1], \
'{} < {} < {}'.format(self._values_range[0], position_value,
self._values_range[1])
pixels = (position_value - self._values_range[0]) * \
(self._page_ctrl_length - self._page_step)
pixels /= (self._values_range[1] - self._values_range[0])
# Correction due to value scaling
pixels = max(0, pixels)
pixels = min(self._page_ctrl_length - self._page_step, pixels)
self._scroll(self.get_rect(), pixels - self._slider_position)
[docs] def get_slider_rect(self) -> 'pygame.Rect':
"""
Get slider rect.
:return: Slider rect
"""
return self._slider_rect.move(*self.get_rect(to_absolute_position=True).topleft)
[docs] def update(self, events: EventVectorType) -> bool:
self.apply_update_callbacks(events)
if self.readonly or not self.is_visible():
return False
rect = self.get_rect(to_absolute_position=True)
for event in events:
# Check mouse over
self._check_mouseover(event)
# User press PAGEUP or PAGEDOWN
if event.type == pygame.KEYDOWN and \
event.key in (pygame.K_PAGEUP, pygame.K_PAGEDOWN) and \
self._keyboard_enabled and self._orientation == 1 and \
not self.scrolling:
direction = 1 if event.key == pygame.K_PAGEDOWN else -1
keys_pressed = pygame.key.get_pressed()
step = self._page_step
if keys_pressed[pygame.K_LSHIFT] or keys_pressed[pygame.K_RSHIFT]:
step *= 0.35
pixels = direction * step
if self._scroll(rect, pixels):
self.change()
return True
# User moves mouse while scrolling
elif (event.type == pygame.MOUSEMOTION and self._mouse_enabled and hasattr(event, 'rel') or
event.type == FINGERMOTION and self._touchscreen_enabled and self._menu is not None) and \
self.scrolling:
# Get relative movement
h = self.get_orientation() == ORIENTATION_HORIZONTAL
rel = event.rel[self._orientation] if event.type == pygame.MOUSEMOTION else (
event.dx * 2 * self._menu.get_window_size()[0] if h else
event.dy * 2 * self._menu.get_window_size()[1]
)
# If mouse outside region and scroll is on limits, ignore
mx, my = pygame.mouse.get_pos() if event.type == pygame.MOUSEMOTION else \
get_finger_pos(self._menu, event)
if self.get_value_percentage() in (0, 1) and \
self.get_scrollarea() is not None and \
self.get_scrollarea().get_parent() is not None:
if self._orientation == 1: # Vertical
h = self._slider_rect.height / 2
if my > (rect.bottom - h) or my < (rect.top + h):
continue
elif self._orientation == 0: # Horizontal
w = self._slider_rect.width / 2
if mx > (rect.right - w) or mx < (rect.left + w):
continue
# Check scrolling
if self._scroll(rect, rel):
self.change()
return True
# Mouse enters or leaves the window
elif event.type == pygame.ACTIVEEVENT:
mx, my = pygame.mouse.get_pos()
if event.gain != 1: # Leave
self._last_mouse_pos = (mx, my)
else:
lmx, lmy = self._last_mouse_pos
self._last_mouse_pos = (-1, -1)
if lmx == -1 or lmy == -1:
continue
if self.scrolling:
if self._orientation == 0: # Horizontal
self._scroll(rect, mx - lmx)
else:
self._scroll(rect, my - lmy)
# User clicks the slider rect
elif event.type == pygame.MOUSEBUTTONDOWN and self._mouse_enabled or \
event.type == FINGERDOWN and self._touchscreen_enabled and \
self._menu is not None:
# Vertical bar: scroll down (4) or up (5). Mouse must be placed
# over the area to enable this feature
if not event.type == FINGERDOWN and \
event.button in (4, 5) and self._orientation == 1 and \
(self._scrollarea is not None and
self._scrollarea.mouse_is_over() or self._scrollarea is None):
direction = -1 if event.button == 4 else 1
if self._scroll(rect, direction * self._single_step):
self.change()
return True
# Click button (left, middle, right)
elif event.type == FINGERDOWN or event.button in (1, 2, 3):
event_pos = get_finger_pos(self._menu, event)
# The _slider_rect origin is related to the widget surface
if self.get_slider_rect().collidepoint(*event_pos):
# Initialize scrolling
self.scrolling = True
self._clicked = True
self._render()
return True
elif rect.collidepoint(*event_pos):
# Moves towards the click by one "page" (= slider length without pad)
s_rect = self.get_slider_rect()
pos = (s_rect.x, s_rect.y)
direction = 1 if event_pos[self._orientation] > pos[self._orientation] else -1
if self._scroll(rect, direction * self._page_step):
self.change()
return True
# User releases mouse button if scrolling
elif (event.type == pygame.MOUSEBUTTONUP and self._mouse_enabled or
event.type == FINGERUP and self._touchscreen_enabled) and self.scrolling:
self._clicked = False
self.scrolling = False
self._render()
return True
return False