Source code for glooey.scrolling
#!/usr/bin/env python3
"""\
Widgets that allow the user to scroll through content that would otherwise be
too big to fit on the screen.
"""
import math
import pyglet
import vecrec
import autoprop
from vecrec import Vector, Rect
from glooey import drawing
from glooey.widget import Widget
from glooey.containers import Bin, Frame, Grid, HVBox, HBox, VBox
from glooey.buttons import Button
from glooey.images import Image
from glooey.misc import Spacer
from glooey.helpers import *
[docs]@autoprop
@register_event_type('on_translate')
class Mover(Bin):
[docs] class TranslateGroup(pyglet.graphics.Group):
[docs] def __init__(self, mover, parent=None):
pyglet.graphics.Group.__init__(self, parent)
self.mover = mover
[docs] def set_state(self):
translation = -self.mover.screen_to_child_coords(0, 0)
pyglet.gl.glPushMatrix()
pyglet.gl.glTranslatef(int(translation.x), int(translation.y), 0)
[docs] def __init__(self):
super().__init__()
# ``child_position`` is the vector between the bottom left corner of
# the child's physical position (i.e. it position in it's own
# coordinates) and it's apparent position (i.e. the position it seems
# to be in after the translation is performed).
self._child_position = Vector.null()
self._translate_group = None
self._expand_horz = True
self._expand_vert = True
@vecrec.accept_anything_as_vector
def pan(self, step):
self.jump(self.position + step)
@vecrec.accept_anything_as_vector
def pan_percent(self, step_percent):
self._require_rects()
self.pan(step_percent * self.unoccupied_size)
@vecrec.accept_anything_as_vector
def jump(self, new_position):
self._require_rects()
self._child_position = new_position - self.child.padded_rect.bottom_left
self._keep_child_in_rect()
self.dispatch_event('on_translate', self)
@vecrec.accept_anything_as_vector
def jump_percent(self, new_percent):
"""
new_percent should be between -1.0 and 1.0. Values outside this range
are not illegal, but they will be clamped into it.
"""
self._require_rects()
self.jump(new_percent * self.unoccupied_size)
[docs] def do_resize_children(self):
# Consult the ``expand_horz`` and ``expand_vert`` member variables to
# decide how much space to give the child. If expansion is enabled,
# the child can occupy the whole mover depending on its alignment. The
# advantage of this is that the child can control its initial position
# using alignment. The downside is that widgets with the default
# "fill" alignment can't move, which is a gotcha. If expansion is
# disabled, the child is made as small as possible.
if self.expand_horz:
child_width = self.rect.width
else:
child_width = self.child.claimed_width
if self.expand_vert:
child_height = self.rect.height
else:
child_height = self.child.claimed_height
# Put the bottom left corner of the child's rectangle at the origin.
# This simplifies the offset calculations, relative to having the
# child's rectangle where the parent's is.
child_rect = Rect.from_size(child_width, child_height)
self.child._resize(child_rect)
self._keep_child_in_rect()
[docs] def do_regroup_children(self):
self._translate_group = self.TranslateGroup(self, self.group)
self.child._regroup(self._translate_group)
[docs] def on_mouse_press(self, x, y, button, modifiers):
x, y = self.screen_to_child_coords(x, y)
super().on_mouse_press(x, y, button, modifiers)
[docs] def on_mouse_release(self, x, y, button, modifiers):
x, y = self.screen_to_child_coords(x, y)
super().on_mouse_release(x, y, button, modifiers)
[docs] def on_mouse_motion(self, x, y, dx, dy):
x, y = self.screen_to_child_coords(x, y)
super().on_mouse_motion(x, y, dx, dy)
[docs] def on_mouse_enter(self, x, y):
x, y = self.screen_to_child_coords(x, y)
super().on_mouse_enter(x, y)
[docs] def on_mouse_leave(self, x, y):
x, y = self.screen_to_child_coords(x, y)
super().on_mouse_leave(x, y)
[docs] def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
x, y = self.screen_to_child_coords(x, y)
super().on_mouse_drag(x, y, dx, dy, buttons, modifiers)
[docs] def on_mouse_drag_enter(self, x, y):
x, y = self.screen_to_child_coords(x, y)
super().on_mouse_drag_enter(x, y)
[docs] def on_mouse_drag_leave(self, x, y):
x, y = self.screen_to_child_coords(x, y)
super().on_mouse_drag_leave(x, y)
[docs] def on_mouse_scroll(self, x, y, scroll_x, scroll_y):
x, y = self.screen_to_child_coords(x, y)
super().on_mouse_scroll(x, y, scroll_x, scroll_y)
[docs] def get_position_percent(self):
percent = Vector.null()
ux, uy = self.unoccupied_size
if ux != 0: percent.x = self.position.x / ux
if uy != 0: percent.y = self.position.y / uy
return percent
@vecrec.accept_anything_as_vector
def pixels_to_percent(self, pixels):
percent = Vector.null()
ux, uy = self.unoccupied_size
if ux != 0: percent.x = pixels.x / ux
if uy != 0: percent.y = pixels.y / uy
return percent
@vecrec.accept_anything_as_vector
def percent_to_pixels(self, percent):
return percent * self.unoccupied_size
@vecrec.accept_anything_as_vector
def screen_to_child_coords(self, screen_coords):
mover_origin = self.rect.bottom_left
mover_coords = screen_coords - mover_origin
child_coords = mover_coords - self._child_position
return child_coords
@vecrec.accept_anything_as_vector
def child_to_screen_coords(self, child_coords):
mover_origin = self.padded_rect.bottom_left
mover_coords = child_coords + self._child_position
screen_coords = pane_coords + pane_origin
return screen_coords
[docs] def _keep_child_in_rect(self):
"""
Update the child position vector to make sure the child can't translate
outside the mover.
"""
self._child_position.x = clamp(
self._child_position.x,
-self.child.padded_rect.left,
self.rect.width - self.child.padded_rect.right,
)
self._child_position.y = clamp(
self._child_position.y,
-self.child.padded_rect.bottom,
self.rect.height - self.child.padded_rect.top,
)
[docs] def _require_rects(self):
if self.child is None:
raise UsageError("can't pan/jump until the mover has a child widget.")
if self.rect is None:
raise UsageError("can't pan/jump until the mover has been given a size.")
if self.child.rect is None:
raise UsageError("can't pan/jump until the mover's child has been given a size.")
[docs]@autoprop
@register_event_type('on_scroll')
@register_event_type('on_resize_children')
class ScrollPane(Widget):
"""
Provide basic support for scrolling.
ScrollPane implements scrolling using a Mover and a scissor box. This
approach is a little counter-intuitive at first, but it builds really well
on the tools and features that already exist. First, the pane creates a
mover that's much bigger than the region that will be visible. The size of
the mover is carefully calculated so that when it's child is all the way at
the bottom, it's top is flush with the top of the visible region, and vice
versa for the bottom. The scissor box is then used to clip everything
outside the visible region.
This widget isn't really meant to be used directly. Instead, it's meant to
be a building block for widgets that need scrolling, like ScrollBox and
Viewport.
"""
Mover = Mover
custom_initial_view = 'top left'
custom_horz_scrolling = False
custom_vert_scrolling = False
[docs] def __init__(self):
super().__init__()
self._mover = self.Mover()
self._mover.push_handlers(self.on_translate)
self._attach_child(self._mover)
self._scissor_group = None
self._apply_initial_view = False
self._initial_view = self.custom_initial_view
self.horz_scrolling = self.custom_horz_scrolling
self.vert_scrolling = self.custom_vert_scrolling
[docs] def add(self, child):
self._mover.add(child)
if self.is_attached_to_gui:
self.view = self.initial_view
else:
self._apply_initial_view = True
@vecrec.accept_anything_as_vector
def scroll(self, step):
self._require_rects()
self._mover.pan(-step)
@vecrec.accept_anything_as_vector
def scroll_percent(self, step_percent):
self._require_rects()
self._mover.pan_percent(-step_percent)
@vecrec.accept_anything_as_vector
def jump(self, new_position):
"""
Parameters
==========
new_position: vector-like
The vector between the bottom left corner of the content and the
bottom left corner of the pane. For example, (0,0) would make the
bottom left corner of the content visible.
"""
self._require_rects()
mover_to_pane = self.rect.bottom_left - self._mover.rect.bottom_left
self._mover.jump(mover_to_pane - new_position)
@vecrec.accept_anything_as_vector
def jump_percent(self, new_percent):
self._require_rects()
self._mover.jump_percent((1,1) - new_percent)
[docs] def do_draw(self):
if self._apply_initial_view and self.child is not None:
self._apply_initial_view = False
self.view = self.initial_view
[docs] def do_claim(self):
# The widget being displayed in the scroll pane can claim however much
# space it wants in the dimension being scrolled. The scroll pane
# itself doesn't need to claim any space in that dimension, because it
# can be as small as it needs to be.
min_width = 0 if self.horz_scrolling else self._mover.claimed_width
min_height = 0 if self.vert_scrolling else self._mover.claimed_height
return min_width, min_height
[docs] def do_resize(self):
# Update the region that will be clipped by OpenGL, unless the widget
# hasn't been assigned a group yet.
if self._scissor_group:
self._scissor_group.rect = self.rect
[docs] def do_resize_children(self):
if not self.child:
return
mover_rect = self.rect.copy()
if self.horz_scrolling:
pane_width = self.rect.width
content_width = self._mover.claimed_width
mover_rect.width = max(2 * content_width - pane_width, pane_width)
if self.vert_scrolling:
pane_height = self.rect.height
content_height = self._mover.claimed_height
mover_rect.height = max(2 * content_height - pane_height, pane_height)
mover_rect.center = self.rect.center
self._mover._resize(mover_rect)
[docs] def do_regroup_children(self):
self._scissor_group = drawing.ScissorGroup(self.rect, self.group)
self._mover._regroup(self._scissor_group)
[docs] def get_position(self):
self._require_rects()
mover_to_pane = self.rect.bottom_left - self._mover.rect.bottom_left
return mover_to_pane - self._mover.position
[docs] def get_view(self):
"""
Return the currently visible rectangle in child coordinates.
"""
mover_to_pane = self.rect.bottom_left - self._mover.rect.bottom_left
mover_to_child = self._mover.position
view_rect = self.rect.copy()
view_rect.bottom_left = mover_to_pane - mover_to_child
return view_rect
[docs] def set_view(self, new_alignment):
self._require_rects()
view_rect = self.rect.copy()
child_rect = self.child.claimed_rect
# Here we're aligning the *view* in the *child*! This is because we're
# going to keep the bottom left corner of the child at (0, 0), and we
# need to figure out how much to translate the child so that we see
# what we want.
drawing.fixed_size_align(
new_alignment, view_rect, child_rect, outside_ok=True)
child_to_view = view_rect.bottom_left - child_rect.bottom_left
self.jump(child_to_view)
[docs] def get_horz_scrolling(self):
# This is a bit of a hack. The mover already has two boolean flags
# that indicate whether or not its child should be given the full
# amount of space available to the mover. We can reuse these as
# scrolling flags, because we want to give the child the full space in
# the dimensions that don't scroll (so that they fill in all the space
# allocated to the scroll pane) and not in those that do (so widgets
# with alignment='fill' can still be scrolled).
return not self._mover.expand_horz
[docs] def set_horz_scrolling(self, new_bool):
# This is a bit of a hack. See get_horz_scrolling() for more info.
self._mover.expand_horz = not new_bool
self._repack()
[docs] def get_vert_scrolling(self):
# This is a bit of a hack. See get_horz_scrolling() for more info.
return not self._mover.expand_vert
[docs] def set_vert_scrolling(self, new_bool):
# This is a bit of a hack. See get_horz_scrolling() for more info.
self._mover.expand_vert = not new_bool
self._repack()
[docs] def _require_rects(self):
if self.child is None:
raise UsageError("can't scroll until the scroll pane has a child widget.")
if self.rect is None:
raise UsageError("can't scroll until the scroll pane has been given a size.")
if self.child.rect is None:
raise UsageError("can't scroll until the scroll pane's child has been given a size.")
[docs]@autoprop
class HVScrollBar(Frame):
HVBox = HVBox
Grip = None
Forward = None
Backward = None
[docs] class GripMover(Mover):
[docs] def __init__(self, bar, grip):
super().__init__()
self.bar = bar
self.pane = bar._pane
self.grip = grip
self.reference_point = None
self.pane.push_handlers(
on_repack=self.on_pane_repack,
on_scroll=self.on_pane_scroll,
)
self.grip.push_handlers(
on_mouse_press=self.on_grip_press,
on_mouse_drag=self.on_grip_drag,
)
self.grip.grab_mouse_on_click = True
self.add(grip)
[docs] def do_resize_children(self):
grip_width, grip_height = self.grip.claimed_size
# Don't try to calculate a scaled grip size if the scroll pane
# doesn't have a child yet. See #36.
if self.bar.scale_grip and self.pane.child:
scaled_width, scaled_height = self.bar._get_scaled_grip_size()
grip_width = clamp(scaled_width, grip_width, self.width)
grip_height = clamp(scaled_height, grip_height, self.height)
# Copied from `Mover.do_resize_children()`; refer there for
# details. Maybe should be more DRY...
child_rect = Rect.from_size(grip_width, grip_height)
self.child._resize(child_rect)
self._keep_child_in_rect()
[docs] def on_grip_drag(self, x, y, dx, dy, buttons, modifiers):
if self.reference_point is not None:
drag_pixels = (x,y) - self.reference_point
drag_percent = self.pixels_to_percent(drag_pixels)
self.pane.scroll_percent(drag_percent)
custom_button_speed = 200
"""\
How fast to scroll (in px/sec) while the forward and backward buttons are
being pressed.
"""
custom_scale_grip = False
"""\
If true, scale the grip such that its size relative to the scroll bar is
the same as the size of the visible region relative to the total scrollable
region.
Note that this option only affects the space made available to the grip;
like any other widget, the space it actually occupies depends on its
alignment, padding, and size hint parameters. Pay particular attention to
alignment. `Button` and `Image`, the two most common grip widgets, have
``'center'`` alignments by default. This means they will not expand to
fill the space available to them unless their alignment is changed to
``'fill'`` or similar.
"""
custom_alignment = 'fill'
[docs] def __init__(self, pane):
super().__init__()
self._pane = pane
self._hvbox = self.HVBox()
self._button_speed = self.custom_button_speed
self._scale_grip = self.custom_scale_grip
if self.Backward is not None:
self._backward = self.Backward()
self._backward.grab_mouse_on_click = True
self._backward.push_handlers(on_mouse_hold=self.on_backward_click)
self._hvbox.add(self._backward, 0)
if self.Grip is not None:
self._grip = self.Grip()
self._mover = self.GripMover(self, self._grip)
self._mover.push_handlers(on_mouse_press=self.on_bar_click)
self._hvbox.add(self._mover)
else:
self._hvbox.add(Spacer())
if self.Forward is not None:
self._forward = self.Forward()
self._forward.grab_mouse_on_click = True
self._forward.push_handlers(on_mouse_hold=self.on_forward_click)
self._hvbox.add(self._forward, 0)
self.add(self._hvbox)
[docs] def on_bar_click(self, x, y, button, modifiers):
x, y = self._mover.screen_to_child_coords(x,y)
# Ignore the click if it's on the grip.
if self._grip.is_under_mouse(x, y):
return
# Jump to where the mouse was clicked.
offset = (x,y) - self._grip.rect.center
step = offset.dot(self.orientation)
size = abs(self._mover.unoccupied_size.dot(self.orientation))
self._pane.scroll_percent(self.orientation * step / size)
[docs] def on_forward_click(self, dt):
self._pane.scroll(dt * self.button_speed * self.orientation)
return True
[docs] def on_backward_click(self, dt):
self._pane.scroll(dt * self.button_speed * -self.orientation)
return True
[docs] def get_orientation(self):
if isinstance(self._hvbox, HBox):
return Vector(1, 0)
elif isinstance(self._hvbox, VBox):
return Vector(0, -1)
else:
raise NotImplementedError
[docs] def _get_scaled_grip_size(self):
# Reimplement in subclasses to account for the direction of the scroll
# bar.
raise NotImplementedError("""\
don't know which direction to scale grip.
This error usual means that you need to inherit your HBar/VBar inner
class(es) from glooey.HScrollBar/glooey.VScrollBar, respectively. If you want
HBar/VBar to inherit common settings from a shared base class, use multiple
inheritance. More details are in the documentation.""")
[docs]@autoprop
class HScrollBar(HVScrollBar):
HVBox = HBox
[docs] def _get_scaled_grip_size(self):
width = self._pane.width**2 / self._pane.child.width
return width, self.height
[docs]@autoprop
class VScrollBar(HVScrollBar):
HVBox = VBox
[docs] def _get_scaled_grip_size(self):
height = self._pane.height**2 / self._pane.child.height
return self.width, height
[docs]@autoprop
class ScrollBox(Widget):
Pane = ScrollPane
Frame = None
HBar = None
VBar = None
Corner = None
custom_mouse_sensitivity = 15 # px/click
[docs] def __init__(self):
super().__init__()
self._grid = Grid(2, 2)
self._grid.set_row_height(1, 0)
self._grid.set_col_width(1, 0)
self._attach_child(self._grid)
self._pane = self.Pane()
self._pane.horz_scrolling = (self.HBar is not None)
self._pane.vert_scrolling = (self.VBar is not None)
self._frame = None
self._hbar = None
self._vbar = None
self._corner = None
if self.Frame:
self._frame = self.Frame()
self._frame.alignment = 'fill'
self._frame.add(self._pane)
self._grid.add(0, 0, self._frame)
else:
self._grid.add(0, 0, self._pane)
if self._pane.horz_scrolling:
self._hbar = self.HBar(self._pane)
self._grid.add(1, 0, self._hbar)
if self._pane.vert_scrolling:
self._vbar = self.VBar(self._pane)
self._grid.add(0, 1, self._vbar)
if self.Corner is not None:
self._corner = self.Corner()
self._grid.add(1, 1, self._corner)
self._mouse_sensitivity = self.custom_mouse_sensitivity
[docs] def on_mouse_scroll(self, x, y, scroll_x, scroll_y):
self._pane.scroll(self.mouse_sensitivity * Vector(scroll_x, scroll_y))
[docs]@autoprop
class Viewport(ScrollPane):
custom_horz_scrolling = True
custom_vert_scrolling = True
custom_sensitivity = 3
[docs] def __init__(self, sensitivity=None):
super().__init__()
self._sensitivity = sensitivity or self.custom_sensitivity
[docs] def do_attach(self):
# If this line raises a pyglet EventException, you're probably trying
# to attach this widget to a GUI that doesn't support mouse pan events.
# See the Viewport documentation for more information.
self.root.push_handlers(self.on_mouse_pan)