Source code for enable.scrolled

# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX
# All rights reserved.
#
# This software is provided without warranty under the terms of the BSD
# license included in LICENSE.txt and may be redistributed only under
# the conditions described in the aforementioned license. The license
# is also available online at http://www.enthought.com/licenses/BSD.txt
#
# Thanks for using Enthought open source!

# Enthought library imports
from traits.api import Any, Bool, DelegatesTo, Float, Instance, Int

# Local, relative imports
from .base import intersect_bounds, empty_rectangle
from .colors import ColorTrait
from .component import Component
from .container import Container
from .viewport import Viewport
from .native_scrollbar import NativeScrollBar


[docs]class Scrolled(Container): """ A Scrolled acts like a viewport with scrollbars for positioning the view position. Rather than subclassing from viewport, it delegates to one. """ # The component that we are viewing component = Instance(Component) # The viewport onto our component viewport_component = Instance(Viewport, ()) # Whether or not the viewport should stay constrained to the bounds # of the viewed component stay_inside = DelegatesTo("viewport_component") # Where to anchor vertically on resizes vertical_anchor = DelegatesTo("viewport_component") # Where to anchor vertically on resizes horizontal_anchor = DelegatesTo("viewport_component") # Inside padding is a background drawn area between the edges or scrollbars # and the scrolled area/left component. inside_padding_width = Int(5) # The inside border is a border drawn on the inner edge of the inside # padding area to highlight the viewport. inside_border_color = ColorTrait("black") inside_border_width = Int(0) # The background color to use for filling in the padding area. bgcolor = ColorTrait("white") # Should the horizontal scrollbar be shown? horiz_scrollbar = Bool(True) # Should the vertical scrollbar be shown? vert_scrollbar = Bool(True) # Should the scrollbars always be shown? always_show_sb = Bool(False) # Should the mouse wheel scroll the viewport? mousewheel_scroll = Bool(True) # Should the viewport update continuously as the scrollbar is dragged, # or only when drag terminates (i.e. the user releases the mouse button) continuous_drag_update = Bool(True) # Override the default value of this inherited trait auto_size = False # ------------------------------------------------------------------------- # Traits for support of geophysics plotting # ------------------------------------------------------------------------- # An alternate vertical scroll bar to control this Scrolled, instead of the # default one that lives outside the scrolled region. alternate_vsb = Instance(Component, transient=True) # The size of the left border space leftborder = Float(0) # A component to lay out to the left of the viewport area (e.g. a depth # scale track) leftcomponent = Any # ------------------------------------------------------------------------- # Private traits # ------------------------------------------------------------------------- _vsb = Instance(NativeScrollBar, transient=True) _hsb = Instance(NativeScrollBar, transient=True) # Stores the last horizontal and vertical scroll positions to avoid # multiple updates in update_from_viewport() _last_hsb_pos = Float(0.0) _last_vsb_pos = Float(0.0) # Whether or not the viewport region is "locked" from updating via # freeze_scroll_bounds() _sb_bounds_frozen = Bool(False) # Records if the horizontal scroll position has been updated while the # Scrolled has been frozen _hscroll_position_updated = Bool(False) # Records if the vertical scroll position has been updated while the # Scrolled has been frozen _vscroll_position_updated = Bool(False) # Whether or not to the scroll bars should cause an event # update to fire on the viewport's view_position. This is used to # prevent redundant events when update_from_viewport() updates the # scrollbar position. _hsb_generates_events = Bool(True) _vsb_generates_events = Bool(True) # ------------------------------------------------------------------------- # Scrolled interface # ------------------------------------------------------------------------- def __init__(self, component, **traits): self.component = component Container.__init__(self, **traits) self._viewport_component_changed()
[docs] def update_bounds(self): self._layout_needed = True if self._hsb is not None: self._hsb._widget_moved = True if self._vsb is not None: self._vsb._widget_moved = True
[docs] def sb_height(self): """ Returns the standard scroll bar height """ # Perhaps a placeholder -- not sure if there's a way to get the # standard width or height of a wx scrollbar -- you can set them to # whatever you want. return 15
[docs] def sb_width(self): """ Returns the standard scroll bar width """ return 15
[docs] def freeze_scroll_bounds(self): """ Prevents the scroll bounds on the scrollbar from updating until unfreeze_scroll_bounds() is called. This is useful on components with view-dependent bounds; when the user is interacting with the scrollbar or the viewport, this prevents the scrollbar from resizing underneath them. """ if not self.continuous_drag_update: self._sb_bounds_frozen = True
[docs] def unfreeze_scroll_bounds(self): """ Allows the scroll bounds to be updated by various trait changes. See freeze_scroll_bounds(). """ self._sb_bounds_frozen = False if self._hscroll_position_updated: self._handle_horizontal_scroll(self._hsb.scroll_position) self._hscroll_position_updated = False if self._vscroll_position_updated: self._handle_vertical_scroll(self._vsb.scroll_position) self._vscroll_position_updated = False self.update_from_viewport() self.request_redraw()
# ------------------------------------------------------------------------- # Trait event handlers # ------------------------------------------------------------------------- def _compute_ranges(self): """ Returns the range_x and range_y tuples based on our component and our viewport_component's bounds. """ comp = self.component viewport = self.viewport_component offset = getattr(comp, "bounds_offset", (0, 0)) ranges = [] for ndx in (0, 1): scrollrange = float(comp.bounds[ndx] - viewport.view_bounds[ndx]) if round(scrollrange / 20.0) > 0.0: ticksize = scrollrange / round(scrollrange / 20.0) else: ticksize = 1 ranges.append( ( offset[ndx], offset[ndx] + comp.bounds[ndx], viewport.view_bounds[ndx], ticksize, ) ) return ranges
[docs] def update_from_viewport(self): """ Repositions the scrollbars based on the current position/bounds of viewport_component. """ if self._sb_bounds_frozen: return x, y = self.viewport_component.view_position range_x, range_y = self._compute_ranges() modify_hsb = self._hsb and x != self._last_hsb_pos modify_vsb = self._vsb and y != self._last_vsb_pos if modify_hsb and modify_vsb: self._hsb_generates_events = False else: self._hsb_generates_events = True if modify_hsb: self._hsb.range = range_x self._hsb.scroll_position = x self._last_hsb_pos = x if modify_vsb: self._vsb.range = range_y self._vsb.scroll_position = y self._last_vsb_pos = y if not self._hsb_generates_events: self._hsb_generates_events = True
def _layout_and_draw(self): self._layout_needed = True self.request_redraw() def _component_position_changed(self, component): self._layout_needed = True def _bounds_changed_for_component(self): self._layout_needed = True self.update_from_viewport() self.request_redraw() def _bounds_items_changed_for_component(self): self.update_from_viewport() def _position_changed_for_component(self): self.update_from_viewport() def _position_items_changed_for_component(self): self.update_from_viewport() def _view_bounds_changed_for_viewport_component(self): self.update_from_viewport() def _view_bounds_items_changed_for_viewport_component(self): self.update_from_viewport() def _view_position_changed_for_viewport_component(self): self.update_from_viewport() def _view_position_items_changed_for_viewport_component(self): self.update_from_viewport() def _component_bounds_items_handler(self, event): if event.added != event.removed: self.update_bounds() def _component_bounds_handler(self, event): old = event.old new = event.new if old is None or new is None or old[0] != new[0] or old[1] != new[1]: self.update_bounds() def _component_changed(self, old, new): if old is not None: old.observe( self._component_bounds_handler, "bounds", remove=True ) old.observe( self._component_bounds_items_handler, "bounds:items", remove=True, ) if new is None: self.component = Container() else: if self.viewport_component: self.viewport_component.component = new new.container = self new.observe(self._component_bounds_handler, "bounds") new.observe( self._component_bounds_items_handler, "bounds:items" ) self._layout_needed = True def _bgcolor_changed(self): self._layout_and_draw() def _inside_border_color_changed(self): self._layout_and_draw() def _inside_border_width_changed(self): self._layout_and_draw() def _inside_padding_width_changed(self): self._layout_needed = True self.request_redraw() def _viewport_component_changed(self): if self.viewport_component is None: self.viewport_component = Viewport( stay_inside=self.stay_inside, vertical_anchor=self.vertical_anchor, horizontal_anchor=self.horizontal_anchor, ) self.viewport_component.view_bounds = self.bounds self.viewport_component.component = self.component self.viewport_component._initialize_position() self.add(self.viewport_component) def _alternate_vsb_changed(self, old, new): self._component_update(old, new) def _leftcomponent_changed(self, old, new): self._component_update(old, new) def _component_update(self, old, new): """ Generic function to manage adding and removing components """ if old is not None: self.remove(old) if new is not None: self.add(new) def _bounds_changed(self, old, new): Component._bounds_changed(self, old, new) self.update_bounds() def _bounds_items_changed(self, event): Component._bounds_items_changed(self, event) self.update_bounds() # ------------------------------------------------------------------------- # Protected methods # ------------------------------------------------------------------------- def _do_layout(self): """ This is explicitly called by _draw(). """ self.viewport_component.do_layout() # Window is composed of border + scrollbar + canvas in each direction. # To compute the overall geometry, first calculate whether component.x # + the border fits in the x size of the window. # If not, add sb, and decrease the y size of the window by the height # of the scrollbar. # Now, check whether component.y + the border is greater than the # remaining y size of the window. If it is not, add a scrollbar and # decrease the x size of the window by the scrollbar width, and perform # the first check again. if not self._layout_needed: return padding = self.inside_padding_width scrl_x_size, scrl_y_size = self.bounds cont_x_size, cont_y_size = self.component.bounds # available_x and available_y are the currently available size for the # viewport available_x = scrl_x_size - 2 * padding - self.leftborder available_y = scrl_y_size - 2 * padding # Figure out which scrollbars we will need need_x_scrollbar = self.horiz_scrollbar and ( (available_x < cont_x_size) or self.always_show_sb ) need_y_scrollbar = ( self.vert_scrollbar and ((available_y < cont_y_size) or self.always_show_sb) ) or self.alternate_vsb if need_x_scrollbar: available_y -= self.sb_height() if need_y_scrollbar: available_x -= self.sb_width() if ((available_x < cont_x_size) and (not need_x_scrollbar) and self.horiz_scrollbar): available_y -= self.sb_height() need_x_scrollbar = True # Put the viewport in the right position self.viewport_component.outer_bounds = [available_x, available_y] container_y_pos = padding if need_x_scrollbar: container_y_pos += self.sb_height() self.viewport_component.outer_position = [ padding + self.leftborder, container_y_pos, ] range_x, range_y = self._compute_ranges() # Create, destroy, or set the attributes of the horizontal scrollbar if need_x_scrollbar: bounds = [available_x, self.sb_height()] hsb_position = [padding + self.leftborder, 0] if not self._hsb: self._hsb = NativeScrollBar( orientation="horizontal", bounds=bounds, position=hsb_position, range=range_x, enabled=False, ) v_pos = self.viewport_component.view_position self._hsb.scroll_position = v_pos[0] self._hsb.observe( self._handle_horizontal_scroll, "scroll_position" ) self._hsb.observe( self._handle_mouse_thumb, "mouse_thumb" ) self.add(self._hsb) else: self._hsb.range = range_x self._hsb.bounds = bounds self._hsb.position = hsb_position elif self._hsb is not None: self._hsb = self._release_sb(self._hsb) else: # We don't need to render the horizontal scrollbar, and we don't # have one to update, either. pass # Create, destroy, or set the attributes of the vertical scrollbar if self.alternate_vsb: self.alternate_vsb.bounds = [self.sb_width(), available_y] self.alternate_vsb.position = [ 2 * padding + available_x + self.leftborder, container_y_pos, ] if need_y_scrollbar and (not self.alternate_vsb): bounds = [self.sb_width(), available_y] vsb_position = [ 2 * padding + available_x + self.leftborder, container_y_pos, ] if not self._vsb: self._vsb = NativeScrollBar( orientation="vertical", bounds=bounds, position=vsb_position, range=range_y, ) v_pos = self.viewport_component.view_position self._vsb.scroll_position = v_pos[1] self._vsb.observe( self._handle_vertical_scroll, "scroll_position" ) self._vsb.observe( self._handle_mouse_thumb, "mouse_thumb" ) self.add(self._vsb) else: self._vsb.bounds = bounds self._vsb.position = vsb_position self._vsb.range = range_y elif self._vsb: self._vsb = self._release_sb(self._vsb) else: # We don't need to render the vertical scrollbar, and we don't # have one to update, either. pass self._layout_needed = False def _release_sb(self, sb): if sb is not None: if sb == self._vsb: sb.observe( self._handle_vertical_scroll, "scroll_position", remove=True, ) if sb == self._hsb: sb.observe( self._handle_horizontal_scroll, "scroll_position", remove=True, ) self.remove(sb) # We shouldn't have to do this, but I'm not sure why the object # isn't getting garbage collected. # It must be held by another object, but which one? sb.destroy() return None def _handle_horizontal_scroll(self, event): position = event.new if self._sb_bounds_frozen: self._hscroll_position_updated = True return c = self.component viewport = self.viewport_component offsetx = getattr(c, "bounds_offset", [0, 0])[0] if position + viewport.view_bounds[0] <= c.bounds[0] + offsetx: if self._hsb_generates_events: viewport.view_position[0] = position else: viewport.trait_setq( view_position=[position, viewport.view_position[1]] ) def _handle_vertical_scroll(self, event): position = event.new if self._sb_bounds_frozen: self._vscroll_position_updated = True return c = self.component viewport = self.viewport_component offsety = getattr(c, "bounds_offset", [0, 0])[1] if position + viewport.view_bounds[1] <= c.bounds[1] + offsety: if self._vsb_generates_events: viewport.view_position[1] = position else: viewport.trait_setq( view_position=[viewport.view_position[0], position] ) def _handle_mouse_thumb(self, event): if event.new == "down" and not self.continuous_drag_update: self.freeze_scroll_bounds() else: self.unfreeze_scroll_bounds() def _draw(self, gc, view_bounds=None, mode="default"): if self.layout_needed: self._do_layout() with gc: self._draw_container(gc, mode) self._draw_inside_border(gc, view_bounds, mode) dx, dy = self.bounds x, y = self.position if view_bounds: tmp = intersect_bounds((x, y, dx, dy), view_bounds) if tmp is empty_rectangle: new_bounds = tmp else: new_bounds = (tmp[0] - x, tmp[1] - y, tmp[2], tmp[3]) else: new_bounds = view_bounds if new_bounds is not empty_rectangle: for component in self.components: if component is not None: with gc: gc.translate_ctm(*self.position) component.draw(gc, new_bounds, mode) def _draw_inside_border(self, gc, view_bounds=None, mode="default"): width_adjustment = self.inside_border_width / 2 left_edge = self.x + 1 + self.inside_padding_width - width_adjustment right_edge = self.x + self.viewport_component.x2 + 2 + width_adjustment bottom_edge = self.viewport_component.y + 1 - width_adjustment top_edge = self.viewport_component.y2 + width_adjustment with gc: gc.set_stroke_color(self.inside_border_color_) gc.set_line_width(self.inside_border_width) gc.rect( left_edge, bottom_edge, right_edge - left_edge, top_edge - bottom_edge, ) gc.stroke_path() # ------------------------------------------------------------------------- # Mouse event handlers # ------------------------------------------------------------------------- def _container_handle_mouse_event(self, event, suffix): """ Implement a container-level dispatch hook that intercepts mousewheel events. (Without this, our components would automatically get handed the event.) """ if self.mousewheel_scroll and suffix == "mouse_wheel": if self.alternate_vsb: self.alternate_vsb._mouse_wheel_changed(event) elif self._vsb: self._vsb._mouse_wheel_changed(event) event.handled = True