#!/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 logging
import warnings
import wx
import wx.lib.newevent as wxevent
log = logging.getLogger(__name__)
_SpinUpEvent, _EVT_SPIN_UP = wxevent.NewEvent()
_SpinDownEvent, _EVT_SPIN_DOWN = wxevent.NewEvent()
EVT_SPIN_UP = _EVT_SPIN_UP
"""Identifier for the :data:`SpinUpEvent` event. """
EVT_SPIN_DOWN = _EVT_SPIN_DOWN
"""Identifier for the :data:`SpinDownEvent` event. """
SpinUpEvent = _SpinUpEvent
"""Event emitted when by a ``SpinButton`` when its up button is pushed. """
SpinDownEvent = _SpinDownEvent
"""Event emitted when by a ``SpinButton`` when its down button is pushed. """
[docs]
class FloatSpinCtrl(wx.Control):
"""A ``FloatSpinCtrl`` is a :class:`wx.Control` 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: Deprecated, has no effect.
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.Control.__init__(self, parent)
if evDelta is not None:
warnings.warn('The evDelta argument is deprecated and has no effect',
category=DeprecationWarning,
stacklevel=2)
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
self.__integer = style & FSC_INTEGER
self.__nolimit = style & FSC_NO_LIMIT
self.__value = value
self.__increment = increment
self.__min = float(minValue)
self.__max = float(maxValue)
self.__range = abs(self.__max - self.__min)
self.__text = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER)
self.__spin = SpinButton( self)
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(EVT_SPIN_UP, self.__onSpinUp)
self.__spin.Bind(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.__min)
[docs]
def GetMax(self):
"""Returns the current maximum value."""
return float(self.__max)
[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.__min, self.__max)
[docs]
def SetMin(self, minval):
"""Sets the minimum value."""
self.SetRange(minval, self.__max)
[docs]
def SetMax(self, maxval):
"""Sets the maximum value."""
self.SetRange(self.__min, maxval)
[docs]
def SetRange(self, minval, maxval):
"""Sets the minimum and maximum values."""
if minval > maxval:
raise ValueError('Min cannot be greater than '
f'max ({minval} > {maxval})')
if self.__integer:
minval = int(round(minval))
maxval = int(round(maxval))
self.__min = float(minval)
self.__max = float(maxval)
self.__range = abs(self.__max - self.__min)
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.__min: newValue = self.__min
if newValue > self.__max: newValue = self.__max
if self.__integer:
newValue = int(round(newValue))
oldValue = self.__value
self.__value = newValue
self.__text.ChangeValue(self.__format.format(newValue))
if not self.__nolimit:
self.__spin.EnableUp( newValue < self.__max)
self.__spin.EnableDown(newValue > self.__min)
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: %s (looking for up [%s] or down [%s])',
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 %s to %s', 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 ``SpinButton`` is pushed.
Decrements the value by the current increment and generates a
:data:`FloatSpinEvent`.
"""
log.debug('Spin down button - attempting to change value from '
'%s to %s', 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`.
"""
log.debug('Spin up button - attempting to change value from '
'%s to %s', 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()
_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.
"""