#------------------------------------------------------------------------------
#
# Copyright (c) 2014-2015, Enthought, Inc.
# 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!
#
#------------------------------------------------------------------------------
from __future__ import division
from math import exp, log
import operator
import six
from traits.api import Any, Callable, Constant, Dict, Enum, Float, Instance, \
Int, List, NO_COMPARE, Str, Tuple, Undefined, Unicode, on_trait_change
from .binder import Binder, QtDynamicProperty, Rename, Default
from .qt import QtCore, QtGui
from .qt.ui_loader import load_ui
from .raw_widgets import ComboBox, Composite, LineEdit, Slider, binder_registry
INVALID_STYLE_RULE = ("*[valid='false'] "
"{ background-color: rgb(255, 192, 192); }")
[docs]class TextField(LineEdit):
""" Simple customization of a LineEdit.
The widget can be configured to update the model on every text change or
only when Enter is pressed (or focus leaves). This emulates Traits UI's
`TextEditor` `auto_set` and `enter_set` configurations.
If a validator is set, invalid text will cause the background to be red.
"""
#: The value to sync with the model.
value = Unicode(comparison_mode=NO_COMPARE)
#: Whether the `value` updates on every keypress, or when Enter is pressed
#: (or `focusOut`).
mode = Enum('auto', 'enter')
#: Whether or not the current value is valid, for the stylesheet.
valid = QtDynamicProperty(True)
[docs] def configure(self):
self.styleSheet = INVALID_STYLE_RULE
def _update_valid(self, text):
""" Update the valid trait based on validation of ``text``.
"""
validator = self.validator
if validator is not None:
state, fixed, pos = validator.validate(text, len(text))
self.valid = (state == validator.Acceptable)
@on_trait_change('textEdited')
def _on_textEdited(self, text):
if (self.mode == 'auto' and
'value' not in self.loopback_guard):
with self.loopback_guard('value'):
self._update_valid(text)
self.value = text
@on_trait_change('editingFinished')
def _on_editingFinished(self):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
self.value = self.text
@on_trait_change('text,validator')
def _on_text(self):
self._update_valid(self.text)
def _value_changed(self, new):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
self.text = new
[docs]class EditableComboBox(ComboBox):
""" ComboBox with an editable text field.
We do not do bidirectional synchronization of the value with the model
since that is typically not required for these use cases.
"""
lineEdit_class = TextField
#: The selected value.
value = Any(Undefined, comparison_mode=NO_COMPARE)
#: (object, label) pairs.
values = List(Tuple(Any, Unicode))
#: Function that is used to compare two objects in the values list for
#: equality. Defaults to normal Python equality.
same_as = Callable(operator.eq)
editable = Constant(True)
@on_trait_change('values,values_items,qobj')
def _update_values(self):
qobj = self.qobj
if qobj is not None:
old_value = self.value
current_text = qobj.currentText()
current_index = qobj.currentIndex()
# Check if the user entered in custom text that should be
# preserved.
preserve_text = (current_index == -1 or
qobj.itemData(current_index) is None or
current_text != qobj.itemText(current_index))
labels = []
new_index = -1
for i, (value, label) in enumerate(self.values):
if self.same_as(value, old_value):
new_index = i
labels.append(label)
with self.loopback_guard('value'):
if qobj.count() > 0:
qobj.clear()
# Items from the list get their index into the values list
# added as their user data as well. Items added from the text
# field will have that still be None.
for i, label in enumerate(labels):
qobj.addItem(label, i)
if preserve_text:
qobj.setEditText(current_text)
self.value = current_text
else:
qobj.setCurrentIndex(new_index)
@on_trait_change('currentIndexChanged_int')
def _on_currentIndexChanged(self, index):
if index != -1 and 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
values_index = self.qobj.itemData(index)
if values_index is not None:
self.value = self.values[values_index][0]
else:
# Otherwise, it's one of the added values.
self.value = self.qobj.itemText(index)
@on_trait_change('lineEdit:textEdited')
def _on_textEdited(self, text):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
self.value = text
[docs]class EnumDropDown(ComboBox):
""" Select from a set of preloaded choices.
"""
#: The selected value.
value = Any(Undefined, comparison_mode=NO_COMPARE)
#: (object, label) pairs.
values = List(Tuple(Any, Unicode))
#: Function that is used to compare two objects in the values list for
#: equality. Defaults to normal Python equality.
same_as = Callable(operator.eq)
editable = Constant(False)
@on_trait_change('values,values_items,qobj')
def _update_values(self):
qobj = self.qobj
if qobj is not None:
old_value = self.value
labels = []
if self.editable:
new_index = -1
else:
new_index = 0
for i, (value, label) in enumerate(self.values):
if self.same_as(value, old_value):
new_index = i
labels.append(label)
if qobj.count() > 0:
qobj.clear()
qobj.addItems(labels)
qobj.setCurrentIndex(new_index)
@on_trait_change('currentIndexChanged_int')
def _on_currentIndexChanged(self, index):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
self.value = self.values[index][0]
def _value_changed(self, new):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
new_index = -1
for i, (value, label) in enumerate(self.values):
if self.same_as(value, new):
new_index = i
break
self.currentIndex = new_index
[docs]class UIFile(Composite):
""" Load a layout from a Qt Designer `.ui` file.
Widgets and layouts with names that do not start with underscores will be
added as traits to this :class:`~.Binder`. The :data:`~.binder_registry`
will be consulted to find the raw :class:`~.Binder` to use for each widget.
This can be overridden for any named widget using the :attr:`overrides`
trait.
In case one wants to let the :class:`~.Binder` to own its widget but just
use the `.ui` file for layout, use the :attr:`insertions` dictionary. The
named widget should be a plain `QWidget` in the UI laid out as desired. The
:class:`~.Binder` will create a new widget as the lone child of this
widget and take up all of its space.
"""
qclass = QtGui.QWidget
#: The .ui file with the layout.
filename = Str()
#: Override binders for named widgets.
overrides = Dict(Str, Instance(Binder))
#: Insert binders as children of the named QWidgets.
insertions = Dict(Str, Instance(Binder))
def __init__(self, filename, **traits):
super(UIFile, self).__init__(filename=filename, **traits)
[docs] def construct(self, *args, **kwds):
qobj, to_be_bound = load_ui(self.filename)
for name in to_be_bound:
obj = qobj.findChild(QtCore.QObject, name)
self.add_trait(name, Instance(Binder))
if name in self.overrides:
binder = self.overrides[name]
binder.qobj = obj
elif name in self.insertions:
binder = self.insertions[name]
binder.construct()
old_layout = obj.layout()
if old_layout is not None:
# Qt hack to replace the layout. We need to ensure that the
# old one is truly deleted. Reparent it onto a widget that
# we then discard.
QtGui.QWidget().setLayout(old_layout)
layout = QtGui.QVBoxLayout(obj)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(binder.qobj)
else:
binder = binder_registry.lookup(obj)()
binder.qobj = obj
setattr(self, name, binder)
self.qobj = qobj
[docs]class BaseSlider(Slider):
""" Base class for the other sliders.
Mostly for interface-checking and common defaults.
"""
#: The value to synch with the model.
value = Any(0)
#: The inclusive range.
range = Tuple(Any(0), Any(99))
#: The underlying Qt value.
qt_value = Rename('value')
# The Qt default is vertical for some awful reason.
orientation = Default(QtCore.Qt.Horizontal)
[docs]class IntSlider(BaseSlider):
#: The value to synch with the model.
value = Int(0)
#: The inclusive range.
range = Tuple(Int(0), Int(99))
def _range_changed(self):
if self.qobj is not None:
self.qobj.setRange(*self.range)
else:
minimum, maximum = self.range
self.trait_set(
minimum=minimum,
maximum=maximum,
)
def _value_changed(self):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
self.qt_value = self.value
@on_trait_change('qt_value')
def _on_qt_value(self):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
self.value = self.qt_value
[docs]class FloatSlider(BaseSlider):
#: The value to synch with the model.
value = Float(0.0)
#: The inclusive range.
range = Tuple(Float(0.0), Float(1.0))
#: The number of steps in the range.
precision = Int(1000)
def _precision_changed(self):
self.maximum = self.precision
def _range_changed(self):
self._value_changed()
def _value_changed(self):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
self.qt_value = self._qt_value_from_python(self.value)
@on_trait_change('qt_value')
def _on_qt_value(self):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
self.value = self._python_value_from_qt(self.qt_value)
def _qt_value_from_python(self, value):
low, high = self.range
precision = self.precision
qt_value = int(round((value - low) * precision / (high - low)))
qt_value = min(max(qt_value, 0), precision)
return qt_value
def _python_value_from_qt(self, qt_value):
low, high = self.range
precision = self.precision
value = qt_value * (high - low) / precision + low
return value
[docs]class LogSlider(FloatSlider):
#: The inclusive range.
range = Tuple(Float(1e-2), Float(100.0))
def _qt_value_from_python(self, value):
low, high = self.range
precision = self.precision
value = max(value, low)
log_low = log(low)
log_high = log(high)
log_value = log(value)
qt_value = int(round((log_value - log_low) *
precision /
(log_high - log_low)))
qt_value = min(max(qt_value, 0), precision)
return qt_value
def _python_value_from_qt(self, qt_value):
low, high = self.range
precision = self.precision
log_low = log(low)
log_high = log(high)
log_value = qt_value * (log_high - log_low) / precision + log_low
value = exp(log_value)
return value
[docs]class RangeSlider(Composite):
""" A slider with labels and a text entry field.
The root widget is a `QWidget` with a new property
`binder_class=RangeSlider`. Stylesheets can reference it using the
selector::
*[binder_class="RangeSlider"] {...}
This can be useful for styling the child `QLabels` and `QLineEdit`, for
example to make a series of `RangeSliders` align.
"""
qclass = QtGui.QWidget
#: The value to synch with the model.
value = Any(0)
#: The inclusive range.
range = Tuple(Any(0), Any(99))
#: The formatting function for the labels.
label_format_func = Callable(six.text_type)
#: The formatting function for the text field. This is used only when the
#: slider is setting the value.
field_format_func = Callable(six.text_type)
#: The slider widget.
slider = Instance(BaseSlider, factory=IntSlider, args=())
#: The field widget.
field = Instance(TextField, args=())
_low_label = Any()
_high_label = Any()
_from_text_func = Callable(int)
def __init__(self, *args, **traits):
# Make sure that a `slider` argument gets assigned before anything else
# because it will affect what range can be accepted.
if 'slider' in traits:
slider = traits.pop('slider')
super(RangeSlider, self).__init__()
self.slider = slider
self.trait_set(**traits)
else:
super(RangeSlider, self).__init__(*args, **traits)
[docs] def construct(self):
self.slider.construct()
self.field.construct()
super(RangeSlider, self).construct()
self.qobj.setProperty('binder_class', u'RangeSlider')
layout = QtGui.QHBoxLayout()
self._low_label = QtGui.QLabel()
self._low_label.setAlignment(QtCore.Qt.AlignRight)
self._high_label = QtGui.QLabel()
self._high_label.setAlignment(QtCore.Qt.AlignLeft)
layout.addWidget(self._low_label)
layout.addWidget(self.slider.qobj)
layout.addWidget(self._high_label)
layout.addWidget(self.field.qobj)
layout.setContentsMargins(0, 0, 0, 0)
self.qobj.setLayout(layout)
@on_trait_change('value,range')
def _update_widgets(self):
# Update the range then the value because the widgets will just
# silently reject values out of bounds.
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
value = self.value
range = self.range
if self.qobj is not None:
self.field.validator.setRange(range[0], range[1])
if not isinstance(self.slider, IntSlider):
# Note: this assumes that all sliders other than
# IntSlider have decimal inputs.
self.field.validator.setDecimals(16)
self._low_label.setText(self.label_format_func(range[0]))
self._high_label.setText(self.label_format_func(range[1]))
self.field.text = six.text_type(value)
self.slider.range = range
self.slider.value = value
@on_trait_change('slider:value')
def _on_slider_value(self):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
value = self.slider.value
self.value = value
self.field.text = self.field_format_func(value)
@on_trait_change('field:value')
def _on_field_text(self, text):
if 'value' not in self.loopback_guard:
with self.loopback_guard('value'):
if self.field.valid:
try:
value = self._from_text_func(text)
except ValueError:
pass
else:
self.value = value
self.slider.value = value