#!/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.
"""