#!/usr/bin/env python
#
# floatspin.py - Alternate implementation to wx.SpinCtrlDouble and
# wx.lib.agw.floatspin.FloatSpin.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`FloatSpinCtrl` class, a spin control for
modifying a floating point value.
"""
import time
import logging
import wx
import wx.lib.newevent as wxevent
import fsleyes_widgets as fw
log = logging.getLogger(__name__)
[docs]
class FloatSpinCtrl(wx.Panel):
"""A ``FloatSpinCtrl`` is a :class:`wx.Panel` which contains a
:class:`wx.TextCtrl` and a :class:`wx.SpinButton`, allowing the user to
modify a floating point (or integer) value.
The ``FloatSpinCtrl`` is an alternative to :class:`wx.SpinCtrl`,
:class:`wx.SpinCtrlDouble`, and :class:`wx.lib.agw.floatspin.FloatSpin`.
- :class:`wx.SpinCtrlDouble`: Under Linux/GTK, this widget does not allow
the user to enter values that are not a multiple of the increment.
- :class:`wx.lib.agw.floatspin.FloatSpin`. Differs from the
:class:`wx.SpinCtrl` API in various annoying ways, and formatting is a
pain.
"""
def __init__(self,
parent,
minValue=None,
maxValue=None,
increment=None,
value=None,
style=None,
width=None,
evDelta=None,
precision=None):
"""Create a ``FloatSpinCtrl``.
The following style flags are available:
.. autosummary::
FSC_MOUSEWHEEL
FSC_INTEGER
FSC_NO_LIMIT
:arg parent: The parent of this control (e.g. a :class:`wx.Panel`).
:arg minValue: Initial minimum value.
:arg maxValue: Initial maximum value.
:arg increment: Default increment to apply when the user changes the
value via the spin button or mouse wheel.
:arg value: Initial value.
:arg style: Style flags - a combination of :data:`FSC_MOUSEWHEEL`,
:data:`FSC_INTEGER`, and :data:`FSC_NO_LIMIT`.
:arg width: If provided, desired text control width (in
characters).
:arg evDelta: Minimum time between consecutive ``wx.SpinButton``
events. On Linux/GTK, the wx.SpinButton is badly
behaved - if, while clicking on the mouse button, the
user moves the mouse even a tiny bit, more than one
spin event will be generated. To work around this
(without having to write my own ``wx.SpinButton``
implementation), the ``evDelta`` parameter allows me
to throttle the maximum rate at which events received
from the spin button can be processed. This is
implemented in the :meth:`__onSpinDown` and
:meth:`__onSpinUp` methods.
This has the side effect that if the user clicks and
holds on the spin button, they have to wait <delta>
seconds between increments/decrements.
:arg precision: The desired precision to the right of the decimal
value. Ignored if the :attr:`FSC_INTEGER` style is
active.
"""
wx.Panel.__init__(self, parent)
if minValue is None: minValue = 0
if maxValue is None: maxValue = 100
if value is None: value = 0
if increment is None: increment = 1
if style is None: style = 0
if evDelta is None: evDelta = 0.5
self.__integer = style & FSC_INTEGER
self.__nolimit = style & FSC_NO_LIMIT
self.__value = value
self.__increment = increment
self.__realMin = float(minValue)
self.__realMax = float(maxValue)
self.__realRange = abs(self.__realMax - self.__realMin)
# Attributes used in spin
# button event rate throttling
self.__lastEvent = time.time()
self.__eventDelta = evDelta
# We use the full signed 32 bit integer
# range offered by the wx.SpinButton class.
self.__realSpinMin = -2 ** 31
self.__realSpinMax = 2 ** 31 - 1
# Unless the no limit style has been
# specified, in which case we map the
# real data range to 16 bits, and
# allow the rest of the 32 bit range
# to account for overflow. In either
# case, the spin button is configured
# to use the full 32 bit range.
if self.__nolimit:
self.__spinMin = -2 ** 15
self.__spinMax = 2 ** 15 - 1
else:
self.__spinMin = self.__realSpinMin
self.__spinMax = self.__realSpinMax
self.__spinRange = abs(self.__spinMax - self.__spinMin)
self.__text = wx.TextCtrl( self,
style=wx.TE_PROCESS_ENTER)
self.__spin = wx.SpinButton(self,
style=wx.SP_VERTICAL | wx.SP_ARROW_KEYS)
self.__spin.SetRange(self.__realSpinMin, self.__realSpinMax)
if width is not None:
width = self.__text.GetTextExtent('0' * width)[0]
self.__text.SetMinSize((width + 10, -1))
self.__text.SetMaxSize((width + 10, -1))
if self.__integer: self.__format = '{:d}'
else: self.__format = '{:.7G}'
if self.__integer: self.__format = '{:d}'
elif precision is None: self.__format = '{:.7G}'
else: self.__format = '{:.' + str(precision) + 'f}'
# Events on key down, enter, focus
# lost, and on the spin control
self.__text.Bind(wx.EVT_KEY_DOWN, self.__onKeyDown)
self.__text.Bind(wx.EVT_TEXT_ENTER, self.__onText)
self.__text.Bind(wx.EVT_KILL_FOCUS, self.__onKillFocus)
self.__spin.Bind(wx.EVT_SPIN_UP, self.__onSpinUp)
self.__spin.Bind(wx.EVT_SPIN_DOWN, self.__onSpinDown)
# Event on mousewheel
# if style enabled
if style & FSC_MOUSEWHEEL:
self.__spin.Bind(wx.EVT_MOUSEWHEEL, self.__onMouseWheel)
self.__text.Bind(wx.EVT_MOUSEWHEEL, self.__onMouseWheel)
# Under linux/GTK, text controls absorb
# mousewheel events, so we bind our own
# handler to prevent this.
elif wx.Platform == '__WXGTK__':
def wheel(ev):
self.GetParent().GetEventHandler().ProcessEvent(ev)
self.__spin.Bind(wx.EVT_MOUSEWHEEL, wheel)
self.__text.Bind(wx.EVT_MOUSEWHEEL, wheel)
# Under linux/GTK, double-clicking the
# textctrl selects the word underneath
# the cursor, whereas we want it to
# select the entire textctrl contents.
# Mouse event behaviour cannot be overridden
# under OSX, but its behaviour is more
# sensible, so a hack is not necessary.
if wx.Platform == '__WXGTK__':
self.__text.Bind(wx.EVT_LEFT_DCLICK, self.__onDoubleClick)
self.__sizer = wx.BoxSizer(wx.HORIZONTAL)
self.__sizer.Add(self.__text, flag=wx.EXPAND, proportion=1)
self.__sizer.Add(self.__spin)
self.Layout()
self.SetSizer( self.__sizer)
self.SetMinSize(self.__sizer.GetMinSize())
self.SetRange(minValue, maxValue)
self.SetIncrement(increment)
@property
def textCtrl(self):
"""Returns a reference to the ``TextCtrl`` contained in this
``FloatSpinCtrl``.
"""
return self.__text
@property
def spinButton(self):
"""Returns a reference to the ``SpinButton`` contained in this
``FloatSpinCtrl``.
"""
return self.__spin
[docs]
def DoGetBestClientSize(self):
"""Returns the best size for this ``FloatSpinCtrl``.
"""
return self.__sizer.GetMinSize()
[docs]
def GetValue(self):
"""Returns the current value."""
return self.__value
[docs]
def GetMin(self):
"""Returns the current minimum value."""
return float(self.__realMin)
[docs]
def GetMax(self):
"""Returns the current maximum value."""
return float(self.__realMax)
[docs]
def GetIncrement(self):
"""Returns the current inrement."""
return self.__increment
[docs]
def SetIncrement(self, inc):
"""Sets the inrement."""
if self.__integer: self.__increment = int(round(inc))
else: self.__increment = inc
[docs]
def GetRange(self):
"""Returns the current data range, a tuple containing the
``(min, max)`` values.
"""
return (self.__realMin, self.__realMax)
[docs]
def SetMin(self, minval):
"""Sets the minimum value."""
self.SetRange(minval, self.__realMax)
[docs]
def SetMax(self, maxval):
"""Sets the maximum value."""
self.SetRange(self.__realMin, maxval)
[docs]
def SetRange(self, minval, maxval):
"""Sets the minimum and maximum values."""
if minval > maxval:
raise ValueError('Min cannot be greater than max '
'({} > {})'.format(minval, maxval))
if self.__integer:
minval = int(round(minval))
maxval = int(round(maxval))
self.__realMin = float(minval)
self.__realMax = float(maxval)
self.__realRange = abs(self.__realMax - self.__realMin)
self.SetValue(self.__value)
[docs]
def SetValue(self, newValue):
"""Sets the value.
:returns ``True`` if the value was changed, ``False`` otherwise.
"""
# Clamp the value so it stays
# within the min/max, unless the
# FSC_NO_LIMIT style flag is set.
if not self.__nolimit:
if newValue < self.__realMin: newValue = self.__realMin
if newValue > self.__realMax: newValue = self.__realMax
if self.__integer:
newValue = int(round(newValue))
oldValue = self.__value
self.__value = newValue
self.__text.ChangeValue(self.__format.format(newValue))
# The wx.SpinButton is badly behaved. It doesn't have
# a ChangeValue method (which would explicitly allow
# us to update the stored spin button value without
# triggering an event), and under GTK, when the
# SetValue method is called from a SPIN_UP/SPIN_DOWN
# event, it will trigger another event. So we disable
# events from the spin button when setting the value.
self.__spin.SetEvtHandlerEnabled(False)
self.__spin.SetValue(self.__realToSpin(newValue))
# We have to re-enable event processing
# asynchronously, otherwise the SpinButton
# will keep generating events. We check
# the state of this control before doing
# so, as the control may get deleted before
# this function gets called.
def reset():
if self and self.__spin:
self.__spin.SetEvtHandlerEnabled(True)
wx.CallAfter(reset)
return newValue != oldValue
def __onKillFocus(self, ev):
"""Called when the text field of this ``FloatSpinCtrl`` loses focus.
Generates an :attr:`.EVT_FLOATSPIN` event.
"""
ev.Skip()
log.debug('Spin lost focus - simulating text event')
self.__onText(ev)
def __onKeyDown(self, ev):
"""Called on ``wx.EVT_KEY_DOWN`` events. If the user pushes the up or
down arrow keys, the value is changed (using the :meth:`__onSpinUp`
and :meth:`__onSpinDown` methods).
"""
up = wx.WXK_UP
down = wx.WXK_DOWN
key = ev.GetKeyCode()
log.debug('Key down event: {} (looking for up [{}] '
'or down [{}])'.format(key, up, down))
if key == up: self.__onSpinUp()
elif key == down: self.__onSpinDown()
else: ev.Skip()
def __onText(self, ev):
"""Called when the user changes the value via the text control.
This method is called when the enter key is pressed.
If the entered value is a valid number, a ``wx.EVT_TEXT_ENTER``
event is generated. This event will have a boolean attribute,
``changed``, which is ``True`` if the value that was stored was
different to that entered by the user (e.g. if it was clamped
to the min/max bounds).
If the value was changed from its previous value, a
:data:`FloatSpinEvent` is also generated.
"""
val = self.__text.GetValue().strip()
log.debug('Spin text - attempting to change value '
'from {} to {}'.format(self.__value, val))
try:
if self.__integer: val = int( val)
else: val = float(val)
except ValueError:
self.SetValue(self.__value)
return
valset = self.SetValue(val)
# Add a 'changed' attribute so
# users can tell if the value
# that was entered was not the
# value that ended up getting
# stored
ev = wx.PyCommandEvent(wx.EVT_TEXT_ENTER.typeId, self.GetId())
ev.changed = val != self.__value
wx.PostEvent(self.GetEventHandler(), ev)
# Emit a spin event if the value
# changed from its previous value
if valset:
wx.PostEvent(self, FloatSpinEvent(value=self.__value))
def __onSpinDown(self, ev=None):
"""Called when the *down* button on the ``wx.SpinButton`` is pushed.
Decrements the value by the current increment and generates a
:data:`FloatSpinEvent`.
"""
# See comments in __init__
if ev is not None:
lastEv = self.__lastEvent
thisEv = time.time()
if thisEv - lastEv < self.__eventDelta:
return
self.__lastEvent = thisEv
log.debug('Spin down button - attempting to change value '
'from {} to {}'.format(self.__value,
self.__value - self.__increment))
if self.SetValue(self.__value - self.__increment):
wx.PostEvent(self, FloatSpinEvent(value=self.__value))
def __onSpinUp(self, ev=None):
"""Called when the *up* button on the ``wx.SpinButton`` is pushed.
Increments the value by the current increment and generates a
:data:`FloatSpinEvent`.
"""
# See comments in __init__
if ev is not None:
lastEv = self.__lastEvent
thisEv = time.time()
if thisEv - lastEv < self.__eventDelta:
return
self.__lastEvent = thisEv
log.debug('Spin up button - attempting to change value '
'from {} to {}'.format(self.__value,
self.__value + self.__increment))
if self.SetValue(self.__value + self.__increment):
wx.PostEvent(self, FloatSpinEvent(value=self.__value))
def __onMouseWheel(self, ev):
"""If the :data:`FSC_MOUSEWHEEL` style flag is set, this method is
called on mouse wheel events.
Calls :meth:`__onSpinUp` on an upwards rotation, and
:meth:`__onSpinDown` on a downwards rotation.
"""
log.debug('Mouse wheel - delegating to spin event handlers')
rot = ev.GetWheelRotation()
if ev.GetWheelAxis() == wx.MOUSE_WHEEL_HORIZONTAL:
rot = -rot
if rot > 0: self.__onSpinUp()
elif rot < 0: self.__onSpinDown()
def __onDoubleClick(self, ev):
"""Called when the user double clicks in the ``TextCtrl``. Selects
the entire contents of the ``TextCtrl``.
"""
self.__text.SelectAll()
def __realToSpin(self, value):
"""Converts the given value from real space to spin button space."""
if self.__realRange == 0:
return 0
if self.__integer:
value = int(round(value))
value = float(value)
spinMin = float(self.__spinMin)
realMin = float(self.__realMin)
realRange = float(self.__realRange)
spinRange = float(self.__spinRange)
value = spinMin + (value - realMin) * (spinRange / realRange)
# Don't allow the value to flow over
# the real wx.SpinButton range.
if value < self.__realSpinMin: value = self.__realSpinMin
elif value > self.__realSpinMax: value = self.__realSpinMax
return int(round(value))
_FloatSpinEvent, _EVT_FLOATSPIN = wxevent.NewEvent()
_FloatSpinEnterEvent, _EVT_FLOATSPIN_ENTER = wxevent.NewEvent()
EVT_FLOATSPIN = _EVT_FLOATSPIN
"""Identifier for the :data:`FloatSpinEvent` event. """
FloatSpinEvent = _FloatSpinEvent
"""Event emitted when the floating point value is changed by the user. A
``FloatSpinEvent`` has the following attributes:
- ``value``: The new value.
"""
FSC_MOUSEWHEEL = 1
"""If set, mouse wheel events on the control will change the value. """
FSC_INTEGER = 2
"""If set, the control stores an integer value, rather than a floating point
value.
"""
FSC_NO_LIMIT = 4
"""If set, the control will allow the user to enter values that are outside
of the current minimum/maximum limits.
"""