Source code for fsleyes_widgets.rangeslider

#!/usr/bin/env python
#
# rangeslider.py - Twin sliders for defining the values of a range.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`RangePanel` and
:class:`RangeSliderSpinPanel` classes, both of which contain controls allowing
the user to modify a range.

The :class:`RangeSliderSpinPanel` is a widget which contains two
:class:`RangePanel` widgets - one with sliders, and one with spinboxes. All
four control widgets are linked.
"""


import logging

import wx
import wx.lib.newevent as wxevent

import numpy as np

from . import floatspin
from . import floatslider
from . import numberdialog


log = logging.getLogger(__name__)


[docs] class RangePanel(wx.Panel): """``RangePanel`` is a widget which contains two sliders or spinboxes (either :class:`.FloatSlider`, or :class:`.FloatSpinCtrl`), allowing a range to be set. When the user changes the low range value to a value beyond the current high value, the high value is increased such that it remains at least a minimum value above the low value. The inverse relationship is also enforced. Whenever the user chenges the *low* or *high* range values, :data:`EVT_LOW_RANGE` or :data:`EVT_HIGH_RANGE` events are generated respectively. A situation may arise whereby a change to one limit will affect the other ( e.g. when enforcing a minimum distance between the values). When this occurs, an :data:`EVT_RANGE` event is generated. So you may need to listen for all three types of event. The following style flags are available: .. autosummary:: RP_INTEGER RP_MOUSEWHEEL RP_SLIDER RP_NO_LIMIT """ def __init__(self, parent, minValue=None, maxValue=None, lowValue=None, highValue=None, lowLabel=None, highLabel=None, minDistance=None, spinWidth=None, style=0): """Create a :class:`RangePanel`. :arg parent: The :mod:`wx` parent object. :arg minValue: Minimum range value. :arg maxValue: Maximum range value. :arg lowLabel: If not ``None``, a :class:`wx.StaticText` widget is placed to the left of the low widget, containing the given label. :arg highLabel: If not ``None``, a :class:`wx.StaticText` widget is placed to the left of the high widget, containing the given label. :arg lowValue: Initial low range value. :arg highValue: Initial high range value. :arg minDistance: Minimum distance to be maintained between low/high values. :arg spinWidth: Desired spin control width. See the :class:`.FloatSpinCtrl` class. :arg style: A combination of :data:`RP_MOUSEWHEEL`, :data:`RP_INTEGER`, :data:`RP_SLIDER`, and :data:`RP_NO_LIMIT`. """ if style & RP_SLIDER: widgetType = 'slider' else: widgetType = 'spin' wx.Panel.__init__(self, parent, style=0) if minValue is None: minValue = 0 if maxValue is None: maxValue = 100 if lowValue is None: lowValue = 0 if highValue is None: highValue = 100 if minDistance is None: minDistance = 0 minValue = float(minValue) maxValue = float(maxValue) lowValue = float(lowValue) highValue = float(highValue) minDistance = float(minDistance) self.__nolimit = style & RP_NO_LIMIT self.__minDistance = minDistance self.__controlType = widgetType if widgetType == 'slider': widgStyle = 0 if style & RP_MOUSEWHEEL: widgStyle |= floatslider.FS_MOUSEWHEEL if style & RP_INTEGER: widgStyle |= floatslider.FS_INTEGER self.__lowWidget = floatslider.FloatSlider(self, style=widgStyle) self.__highWidget = floatslider.FloatSlider(self, style=widgStyle) self.__lowWidget .Bind(wx.EVT_SLIDER, self.__onLowChange) self.__highWidget.Bind(wx.EVT_SLIDER, self.__onHighChange) elif widgetType == 'spin': widgStyle = 0 if style & RP_MOUSEWHEEL: widgStyle |= floatspin.FSC_MOUSEWHEEL if style & RP_INTEGER: widgStyle |= floatspin.FSC_INTEGER if style & RP_NO_LIMIT: widgStyle |= floatspin.FSC_NO_LIMIT self.__lowWidget = floatspin.FloatSpinCtrl(self, style=widgStyle, width=spinWidth) self.__highWidget = floatspin.FloatSpinCtrl(self, style=widgStyle, width=spinWidth) self.__lowWidget .Bind(floatspin.EVT_FLOATSPIN, self.__onLowChange) self.__highWidget.Bind(floatspin.EVT_FLOATSPIN, self.__onHighChange) # Widgets under Linux/GTK absorb mouse # wheel events, so we bind a handler # to prevent this. if not (style & RP_MOUSEWHEEL) and wx.Platform == '__WXGTK__': def wheel(ev): self.GetParent().GetEventHandler().ProcessEvent(ev) self.Bind(wx.EVT_MOUSEWHEEL, wheel) self.__sizer = wx.GridBagSizer(1, 1) self.__sizer.SetEmptyCellSize((0, 0)) self.SetSizer(self.__sizer) self.__sizer.Add(self.__lowWidget, pos=(0, 1), flag=wx.EXPAND | wx.ALL) self.__sizer.Add(self.__highWidget, pos=(1, 1), flag=wx.EXPAND | wx.ALL) if lowLabel is not None: self.__lowLabel = wx.StaticText(self, label=lowLabel) self.__sizer.Add(self.__lowLabel, pos=(0, 0), flag=wx.EXPAND | wx.ALL) if highLabel is not None: self.__highLabel = wx.StaticText(self, label=highLabel) self.__sizer.Add(self.__highLabel, pos=(1, 0), flag=wx.EXPAND | wx.ALL) self.SetLimits(minValue, maxValue) self.__lowWidget .SetValue(lowValue) self.__highWidget.SetValue(highValue) self.SetDistance(minDistance) self.__sizer.AddGrowableCol(1) self.Layout() @property def lowWidget(self): """Returns the low widget, either a ``FloatSlider`` or ``FloatSpinCtrl``. """ return self.__lowWidget @property def highWidget(self): """Returns the high widget, either a ``FloatSlider`` or ``FloatSpinCtrl``. """ return self.__highWidget def __onLowChange(self, ev=None): """Called when the user changes the low widget. Attempts to make sure that the high widget is at least (low value + min distance), then posts a :data:`RangeEvent`. """ lowValue = self.GetLow() highValue = self.GetHigh() distance = self.GetDistance() newHigh = highValue if lowValue > highValue: newHigh = lowValue + distance self.SetRange(lowValue, newHigh) newLow, newHigh = self.GetRange() # If the high value changed as a result of # the low value changing, we emit a RangeEvent. # Otherwise we emit a LowRangeEvent. if np.isclose(newHigh, highValue): ev = LowRangeEvent else: ev = RangeEvent log.debug('Low range value changed - posting {}: ' '[{} - {}]'.format(ev, newLow, newHigh)) ev = ev(low=newLow, high=newHigh) ev.SetEventObject(self) wx.PostEvent(self, ev) def __onHighChange(self, ev=None): """Called when the user changes the high widget. Attempts to make sure that the low widget is at least (high value - min distance), then posts a :data:`RangeEvent`. """ lowValue = self.GetLow() highValue = self.GetHigh() distance = self.GetDistance() newLow = lowValue if highValue < lowValue: newLow = highValue - distance self.SetRange(newLow, highValue) newLow, newHigh = self.GetRange() if np.isclose(newLow, lowValue): ev = HighRangeEvent else: ev = RangeEvent log.debug('High range value changed - posting {}: ' '[{} - {}]'.format(ev, newLow, newHigh)) ev = ev(low=newLow, high=newHigh) ev.SetEventObject(self) wx.PostEvent(self, ev)
[docs] def GetDistance(self): """Returns the minimum distance that is maintained between the low/high range values. """ return self.__minDistance
[docs] def SetDistance(self, distance): """Sets the minimum distance to be maintained between the low/high range values. """ lo, hi = self.GetLimits() if distance < 0 or distance > (hi - lo): raise ValueError('Invalid distance: {}'.format(distance)) self.__minDistance = distance self.SetRange(*self.GetRange())
[docs] def GetLow(self): """Returns the current low range value.""" return self.__lowWidget.GetValue()
[docs] def GetHigh(self): """Returns the current high range value.""" return self.__highWidget.GetValue()
[docs] def SetLow(self, lowValue): """Set the current low range value """ self.SetRange(lowValue, self.GetHigh())
[docs] def SetHigh(self, highValue): """Set the current high range value """ self.SetRange(self.GetLow(), highValue)
[docs] def GetRange(self): """Returns a tuple containing the current (low, high) range values.""" return (self.GetLow(), self.GetHigh())
[docs] def SetRange(self, lowValue, highValue): """Sets the current (low, high) range values, making sure that they are at within the low/high limits, and least (min distance) apart. """ minLow, maxHigh = self.GetLimits() dist = self.GetDistance() if not np.isclose(lowValue, highValue) and highValue < lowValue: raise ValueError('high [{}] < low [{}]'.format( highValue, lowValue)) if not self.__nolimit: if lowValue < minLow: lowValue = minLow if highValue > maxHigh: highValue = maxHigh if highValue - lowValue < dist: centre = lowValue + (highValue - lowValue) / 2.0 halfdist = dist / 2.0 if centre < (minLow + halfdist): centre = minLow + halfdist elif centre > (maxHigh - halfdist): centre = maxHigh - halfdist lowValue = centre - dist / 2.0 highValue = centre + dist / 2.0 log.debug('Setting range: {}'.format((lowValue, highValue))) self.__lowWidget .SetValue(lowValue) self.__highWidget.SetValue(highValue)
[docs] def GetLimits(self): """Returns a tuple containing the current (minimum, maximum) range limit values. """ return (self.GetMin(), self.GetMax())
[docs] def SetLimits(self, minValue, maxValue): """Sets the current (minimum, maximum) range limit values.""" if not np.isclose(minValue, maxValue) and maxValue < minValue: raise ValueError('max [{}] < min [{}]'.format(maxValue, minValue)) if maxValue - minValue < self.GetDistance(): raise ValueError( 'Invalid limits (range {} - {} is less than distance ' '{})'.format(minValue, maxValue, self.GetDistance())) log.debug('Setting limits: {}'.format((minValue, maxValue))) self.__lowWidget .SetRange(minValue, maxValue) self.__highWidget.SetRange(minValue, maxValue) self.SetRange(*self.GetRange())
[docs] def GetMin(self): """Returns the current minimum range value.""" return self.__lowWidget.GetMin()
[docs] def GetMax(self): """Returns the current maximum range value.""" return self.__highWidget.GetMax()
[docs] def SetMin(self, minValue): """Sets the current minimum range value.""" self.SetLimits(minValue, self.GetMax())
[docs] def SetMax(self, maxValue): """Sets the current maximum range value.""" self.SetLimits(self.GetMin(), maxValue)
[docs] class RangeSliderSpinPanel(wx.Panel): """A :class:`wx.Panel` which contains two sliders and two spinboxes. The sliders and spinboxes are contained within two :class:`RangePanel` instances respectively. One slider and spinbox pair is used to edit the *low* value of a range, and the other slider/spinbox used to edit the *high* range value. Buttons are optionally displayed on either end which display the minimum/maximum limits and, when clicked, allow the user to modify said limits. The ``RangeSliderSpinPanel`` forwards events from the :class:`RangePanel` instances when the user edits the *low*/*high* range values, and generates a :data:`RangeLimitEvent` when the user edits the range limits. The following style flags are available: .. autosummary:: RSSP_INTEGER RSSP_MOUSEWHEEL RSSP_SHOW_LIMITS RSSP_EDIT_LIMITS RSSP_NO_LIMIT A ``RangeSliderSpinPanel`` will look something like this: .. image:: images/rangesliderspinpanel.png :scale: 50% :align: center """ def __init__(self, parent, minValue=None, maxValue=None, lowValue=None, highValue=None, minDistance=None, lowLabel=None, highLabel=None, spinWidth=None, style=None): """Create a :class:`RangeSliderSpinPanel`. :arg parent: The :mod:`wx` parent object. :arg minValue: Minimum low value. :arg maxValue: Maximum high value. :arg lowValue: Initial low value. :arg highValue: Initial high value. :arg minDistance: Minimum distance to maintain between low and high values. :arg lowLabel: If not ``None``, a :class:`wx.StaticText` widget is placed to the left of the low slider, containing the label. :arg highLabel: If not ``None``, a :class:`wx.StaticText` widget is placed to the left of the high slider, containing the label. :arg spinWidth: Desired spin control width. Defaults to 6. See the :class:`.FloatSpinCtrl` class. :arg style: A combination of :data:`RSSP_INTEGER`, :data:`RSSP_MOUSEWHEEL`, :data:`RSSP_SHOW_LIMITS`, :data:`RSSP_EDIT_LIMITS`, and :data:`RSSP_NO_LIMIT`. Defaults to :data:`RSSP_SHOW_LIMITS`. """ if spinWidth is None: spinWidth = 6 if style is None: style = RSSP_SHOW_LIMITS showLimits = style & RSSP_SHOW_LIMITS editLimits = style & RSSP_EDIT_LIMITS mousewheel = style & RSSP_MOUSEWHEEL real = not style & RSSP_INTEGER limit = not style & RSSP_NO_LIMIT wx.Panel.__init__(self, parent) if not showLimits: editLimits = False self.__editLimits = editLimits self.__showLimits = showLimits if real: self.__fmt = '{: 0.3G}' else: self.__fmt = '{}' params = { 'minValue' : minValue, 'maxValue' : maxValue, 'lowValue' : lowValue, 'highValue' : highValue, 'minDistance' : minDistance, } style = 0 if mousewheel: style |= RP_MOUSEWHEEL if not real: style |= RP_INTEGER if not limit: style |= RP_NO_LIMIT self.__sliderPanel = RangePanel( self, lowLabel=lowLabel, highLabel=highLabel, spinWidth=spinWidth, style=style | RP_SLIDER, **params) self.__spinPanel = RangePanel( self, style=style, spinWidth=spinWidth, **params) self.__sizer = wx.BoxSizer(wx.HORIZONTAL) self.SetSizer(self.__sizer) self.__sizer.Add(self.__sliderPanel, flag=wx.EXPAND, proportion=1) self.__sizer.Add(self.__spinPanel, flag=wx.EXPAND) self.__sliderPanel.Bind(EVT_RANGE, self.__onRangeChange) self.__spinPanel .Bind(EVT_RANGE, self.__onRangeChange) self.__sliderPanel.Bind(EVT_LOW_RANGE, self.__onRangeChange) self.__spinPanel .Bind(EVT_LOW_RANGE, self.__onRangeChange) self.__sliderPanel.Bind(EVT_HIGH_RANGE, self.__onRangeChange) self.__spinPanel .Bind(EVT_HIGH_RANGE, self.__onRangeChange) if showLimits: self.__minButton = wx.Button(self) self.__maxButton = wx.Button(self) self.__sizer.Insert(0, self.__minButton, flag=wx.EXPAND | wx.ALL) self.__sizer.Add( self.__maxButton, flag=wx.EXPAND | wx.ALL) minValue, maxValue = self.__sliderPanel.GetLimits() self.__minButton.SetLabel(self.__fmt.format(minValue)) self.__maxButton.SetLabel(self.__fmt.format(maxValue)) self.__minButton.Enable(editLimits) self.__maxButton.Enable(editLimits) self.__minButton.Bind(wx.EVT_BUTTON, self.__onLimitButton) self.__maxButton.Bind(wx.EVT_BUTTON, self.__onLimitButton) # Widgets under Linux/GTK absorb mouse # wheel events, so we bind a handler # to prevent this. if not mousewheel and wx.Platform == '__WXGTK__': def wheel(ev): self.GetParent().GetEventHandler().ProcessEvent(ev) self.Bind(wx.EVT_MOUSEWHEEL, wheel) self.Layout() @property def lowSlider(self): """Returns the ``FloatSlider`` for the low range. """ return self.__sliderPanel.lowWidget @property def highSlider(self): """Returns the ``FloatSlider`` for the high range. """ return self.__sliderPanel.highWidget @property def lowSpin(self): """Returns the ``FloatSpinCtrl`` for the low range. """ return self.__spinPanel.lowWidget @property def highSpin(self): """Returns the ``FloatSpinCtrl`` for the high range. """ return self.__spinPanel.highWidget @property def minButton(self): """Returns the button to edit the lower range limit, or ``None`` if ``RSSP_EDIT_LIMITS`` is not active. """ if self.__editLimits: return self.__minButton else: return None @property def maxButton(self): """Returns the button to edit the upper range limit, or ``None`` if ``RSSP_EDIT_LIMITS`` is not active. """ if self.__editLimits: return self.__maxButton else: return None def __onRangeChange(self, ev): """Called when the user edits the limits on either the slider or spinboxes. Syncs the change between the sliders and spinboxes, and re-posts the event. """ source = ev.GetEventObject() slider = self.__sliderPanel spin = self.__spinPanel # Get a reference to the panel # that needs to be synced. if source is slider: slave = spin elif source is spin: slave = slider slave.SetRange(*source.GetRange()) log.debug('Range values changed - posting event: [{}]'.format( source.GetRange())) ev.SetEventObject(self) wx.PostEvent(self, ev) def __onLimitButton(self, ev): """Called when one of the min/max buttons is pushed. Pops up a dialog prompting the user to enter a new value, and updates the range limits accordingly. Emits a :data:`RangeLimitEvent`. """ source = ev.GetEventObject() if source == self.__minButton: labeltxt = 'New minimum value' initVal = self.GetMin() minVal = None maxVal = self.GetMax() elif source == self.__maxButton: labeltxt = 'New maximum value' initVal = self.GetMax() minVal = self.GetMin() maxVal = None else: return dlg = numberdialog.NumberDialog( self.GetTopLevelParent(), message=labeltxt, initial=initVal, minValue=minVal, maxValue=maxVal) pos = ev.GetEventObject().GetScreenPosition() dlg.SetPosition(pos) if dlg.ShowModal() != wx.ID_OK: return # The NumberDialog should not return an # invalid value (i.e. a min > the current # max, or vice versa). But just in case, # we absorb value errors raised by the # SetMin/SetMax/SetLimits methods. try: if source == self.__minButton: self.SetMin(dlg.GetValue()) elif source == self.__maxButton: self.SetMax(dlg.GetValue()) except ValueError: return ev = RangeLimitEvent(min=self.GetMin(), max=self.GetMax()) ev.SetEventObject(self) wx.PostEvent(self, ev)
[docs] def GetDistance(self): """Returns the minimum distance between the low/high range values. """ return self.__sliderPanel.GetDistance()
[docs] def SetDistance(self, distance): """Sets the minimum distance between the low/high range values. """ self.__sliderPanel.SetDistance(distance) self.__spinPanel .SetDistance(distance)
[docs] def GetLimits(self): """Returns the minimum/maximum range values. """ return self.__sliderPanel.GetLimits()
[docs] def SetLimits(self, minValue, maxValue): """Sets the minimum/maximum range values.""" self.__sliderPanel.SetLimits(minValue, maxValue) self.__spinPanel .SetLimits(minValue, maxValue) if self.__showLimits: self.__minButton.SetLabel(self.__fmt.format(minValue)) self.__maxButton.SetLabel(self.__fmt.format(maxValue))
[docs] def SetMin(self, minValue): """Sets the minimum range value.""" self.SetLimits(minValue, self.GetMax())
[docs] def SetMax(self, maxValue): """Sets the maximum range value.""" self.SetLimits(self.GetMin(), maxValue)
[docs] def GetMin(self): """Returns the minimum range value.""" return self.__sliderPanel.GetMin()
[docs] def GetMax(self): """Returns the maximum range value.""" return self.__sliderPanel.GetMax()
[docs] def GetLow( self): """Returns the current low range value.""" return self.__sliderPanel.GetLow()
[docs] def GetHigh(self): """Returns the current high range value.""" return self.__sliderPanel.GetHigh()
[docs] def SetLow(self, lowValue): """Sets the current low range value.""" self.SetRange(lowValue, self.GetHigh())
[docs] def SetHigh(self, highValue): """Sets the current high range value.""" self.SetRange(self.GetLow(), highValue)
[docs] def GetRange(self): """Return the current (low, high) range values.""" return self.__sliderPanel.GetRange()
[docs] def SetRange(self, lowValue, highValue): """Set the current low and high range values.""" self.__sliderPanel.SetRange(lowValue, highValue) self.__spinPanel .SetRange(lowValue, highValue)
RP_INTEGER = 1 """If set, the :class:`RangePanel` stores integer values, rather than floating point. """ RP_MOUSEWHEEL = 2 """If set, the user will be able to change the range values with the mouse wheel. """ RP_SLIDER = 4 """If set, :class:`.FloatSlider` widgets will be used to control the range values. If not set, :class:`.FloatSpinCtrl` widgets are used. """ RP_NO_LIMIT = 8 """If set, and :attr:`RP_SLIDER` is not set, the user will be able to enter values into the spin controls that are beyond the current minimum/maximum range values. """ RSSP_INTEGER = 1 """If set, the :class:`RangeSliderSpinPanel` stores integer values, rather than floating point. """ RSSP_MOUSEWHEEL = 2 """If set, the user will be able to change the range values with the mouse wheel. """ RSSP_SHOW_LIMITS = 4 """If set, the minimum/maximum range values are shown alongside the range controls. """ RSSP_EDIT_LIMITS = 8 """If set, and :data:`RSSP_SHOW_LIMITS` is also set, the minimum/maximum range values are shown alongside the range controls on buttons. When the presses a button, a dialog is displayed allowing them to change the range limits. """ RSSP_NO_LIMIT = 16 """If set, the user is able to enter values into the spin controls which are outside of the current minimum/maximum. """ _RangeEvent, _EVT_RANGE = wxevent.NewEvent() _LowRangeEvent, _EVT_LOW_RANGE = wxevent.NewEvent() _HighRangeEvent, _EVT_HIGH_RANGE = wxevent.NewEvent() _RangeLimitEvent, _EVT_RANGE_LIMIT = wxevent.NewEvent() EVT_RANGE = _EVT_RANGE """Identifier for the :data:`RangeEvent`.""" EVT_LOW_RANGE = _EVT_LOW_RANGE """Identifier for the :data:`LowRangeEvent`.""" EVT_HIGH_RANGE = _EVT_HIGH_RANGE """Identifier for the :data:`HighRangeEvent`.""" EVT_RANGE_LIMIT = _EVT_RANGE_LIMIT """Identifier for the :data:`RangeLimitEvent`.""" RangeEvent = _RangeEvent """Event emitted by :class:`RangePanel` and :class:`RangeSliderSpinPanel` objects when either of their low or high values change. Contains two attributes, ``low`` and ``high``, containing the new low/high range values. """ LowRangeEvent = _LowRangeEvent """Event emitted by :class:`RangePanel` and :class:`RangeSliderSpinPanel` objects when their low value changes. Contains one attributes, ``low``, containing the new low range value. """ HighRangeEvent = _HighRangeEvent """Event emitted by :class:`RangePanel` and :class:`RangeSliderSpinPanel` objects when their high value changes. Contains one attributes, ``high``, containing the new high range value. """ RangeLimitEvent = _RangeLimitEvent """Event emitted by :class:`RangeSliderSpinPanel` objects when the user modifies the range limits. Contains two attributes, ``min`` and ``max``, containing the new minimum/maximum range limits. """