"""
Utility classes for creating widgets holding physical quantities.
"""
__all__ = [
'AffixControlBase',
'DoubleValidator',
'ExpressionValidator',
'ValueControlBase',
'QuantityControlBase',
'QuantityDisplay',
]
from abc import abstractmethod
import string
import re
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QKeySequence, QValidator
from PyQt5.QtWidgets import QLineEdit, QWidget
from cpymad.util import check_expression
from madgui.util.unit import units, get_raw_label, get_unit, tounit
from madgui.util.signal import Signal
from madgui.util.misc import cachedproperty
import madgui.core.config as config
Acceptable = QValidator.Acceptable
Intermediate = QValidator.Intermediate
Invalid = QValidator.Invalid
def asb_property(name):
key = '_' + name
def get(self):
return getattr(self, key)
def set(self, value):
if value != get(self):
setattr(self, key, value)
self.updateEdit()
get.__name__ = str('get_' + name)
set.__name__ = str('set_' + name)
return property(get, set)
[docs]class AffixControlBase:
"""
Base class for controls showing a prefix/suffix surrounding an editable
value in a QLineEdit.
"""
validator = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.line_edit().textChanged.connect(self.interpretText)
# imitate QAbstractSpinBox/QSpinBox/QDoubleSpinBox API
[docs] def get_value(self):
return self._value
[docs] def set_value(self, value, update=True):
value = self.sanitize(value)
if value == self._value:
return
self._value = value
self.valueChanged.emit(value)
if update:
self.updateEdit()
prefix = asb_property('prefix')
suffix = asb_property('suffix')
value = property(get_value, set_value)
placeholder_text = ""
_prefix = ""
_suffix = ""
_value = None
# abstract methods
[docs] @abstractmethod
def sanitize(self, value):
return value
[docs] @abstractmethod
def line_edit(self):
raise NotImplementedError
[docs] @abstractmethod
def parse(self, value):
return value
# utility methods
[docs] def stripped(self, text):
if text.startswith(self.prefix):
text = text[len(self.prefix):]
if text.endswith(self.suffix) and self.suffix:
text = text[:-len(self.suffix)]
return text
# def text(self):
# return self.line_edit().text()
# QAbstractSpinBox replacements (non-virtual methods)
[docs] def interpretText(self):
edit = self.line_edit()
state, text, pos = self.validate(edit.text(), edit.cursorPosition())
if state == Acceptable:
self.set_value(self.valueFromText(text), update=False)
[docs] def updateEdit(self):
# taken from QAbstractSpinBoxPrivate::updateEdit
old = self.text()
new = self.textFromValue(self.value)
if new == old:
return
edit = self.line_edit()
pos = edit.cursorPosition() # TODO: must use selectionStart()?
sel = len(edit.selectedText())
sb = edit.blockSignals(True)
edit.setText(new)
pos = max(pos, len(self.prefix))
pos = min(pos, len(new) - len(self.suffix))
if sel > 0:
edit.setSelection(pos, sel)
else:
edit.setCursorPosition(pos)
edit.blockSignals(sb)
self.updateGeometry()
[docs] def selectAll(self):
if self.value is None:
beg = 0
end = len(self.text())
else:
beg = len(self.prefix)
end = len(self.text()) - len(self.suffix)
self.line_edit().setSelection(beg, end-beg)
[docs] def valueFromText(self, text):
if text == self.placeholder_text:
return None
return self.parse(self.stripped(text))
[docs] def textFromValue(self, value):
if value is None:
return self.placeholder_text
return self.prefix + self.format(value) + self.suffix
[docs] def validate(self, text, pos):
# strip prefix
if not text.startswith(self.prefix):
return Invalid, text, pos
text = text[len(self.prefix):]
pos -= len(self.prefix)
if pos < 0:
pos = 0
# strip suffix
if not text.endswith(self.suffix):
return Invalid, text, pos
if self.suffix:
text = text[:-len(self.suffix)]
if pos >= len(text):
pos = len(text)
# allow empty value
state, text, pos = self._validate_value(text, pos)
# fix prefix/suffix
text = self.prefix + text + self.suffix
pos += len(self.prefix)
return state, text, pos
def _validate_value(self, text, pos):
if not text or self.validator is None:
return Acceptable, text, pos
return self.validator.validate(text, pos)
# QWidget overrides
[docs] def focusInEvent(self, event):
edit = self.line_edit()
if edit is self:
# avoid infinite recursion
super().focusInEvent(event)
else:
self.line_edit().event(event)
# skip QAbstractSpinBox::focusInEvent (which would call the
# non-virtual selectAll)
QWidget.focusInEvent(self, event)
if event.reason() in (Qt.TabFocusReason, Qt.BacktabFocusReason):
self.selectAll()
[docs] def keyPressEvent(self, event):
if event.key() in (Qt.Key_End, Qt.Key_Home):
edit = self.line_edit()
pos = edit.cursorPosition()
beg = len(self.prefix)
end = len(self.text()) - len(self.suffix)
if pos < beg or pos > end:
# let lineedit handle this
edit.event(event)
return
if event.key() == Qt.Key_End:
dest = end
if event.key() == Qt.Key_Home:
dest = beg
if event.modifiers() & Qt.ShiftModifier:
edit.setSelection(pos, dest-pos)
else:
edit.setCursorPosition(dest)
event.accept()
return
if event == QKeySequence.SelectAll:
self.selectAll()
event.accept()
return
super().keyPressEvent(event)
[docs]class DoubleValidator(QValidator):
"""
Use this validator instead of QDoubleValidator to avoid allowing
numbers in the current locale…
"""
minimum = None
maximum = None
_ALLOWED_CHARS = set(string.digits + "eE+-.")
_INTERMEDIATE = re.compile(r'^[+-]?\d*\.?\d*[eE]?[+-]?\d*$')
[docs] def validate(self, text, pos):
text = text.replace(",", ".")
if not (set(text) <= self._ALLOWED_CHARS):
return Invalid, text, pos
try:
value = float(text)
except ValueError:
return self._check_invalid(text, pos)
return self._check_valid(value), text, pos
def _check_valid(self, value):
minimum, maximum = self.minimum, self.maximum
if minimum is not None and value < minimum:
return Intermediate
if maximum is not None and value > maximum:
return Intermediate
return Acceptable
def _check_invalid(self, text, pos):
# TODO: get smarter, i.e. require
# - single edit
# - at current position
# or similar? —I guess, that's not worth the effort…
if self._INTERMEDIATE.match(text):
return Intermediate, text, pos
return Invalid, text, pos
[docs]class ExpressionValidator(QValidator):
_ALLOWED_CHARS = set("+-/*^()->._ " + string.ascii_letters + string.digits)
[docs] def validate(self, text, pos):
return self._validate(text), text, pos
def _validate(self, text):
if not self._ALLOWED_CHARS.issuperset(text):
return Invalid
try:
check_expression(text)
return Acceptable
except ValueError:
return Intermediate
[docs] def parse(self, text):
return text
[docs]class ValueControlBase(AffixControlBase):
"""
Base class for widgets displaying values from an ordered set.
"""
[docs] def get_range(self):
return (self.minimum, self.maximum)
[docs] def set_range(self, minimum, maximum):
self.minimum = minimum
self.maximum = maximum
minimum = asb_property('minimum')
maximum = asb_property('maximum')
range = property(get_range, set_range)
_minimum = None
_maximum = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setAlignment(Qt.AlignRight)
[docs] def sanitize(self, value):
if not isinstance(value, (float, int)):
return value
minimum, maximum = self.minimum, self.maximum
if minimum is not None and value < minimum:
value = minimum
if maximum is not None and value > maximum:
value = maximum
return self.round_value(value)
[docs] def round_value(self, value):
return self.valueFromText(self.textFromValue(value))
[docs]class QuantityControlBase(ValueControlBase):
"""
Base class for widgets displaying physical quantities.
"""
_unit = None
def __init__(self, parent=None, value=None, unit=None):
super().__init__(parent)
self.validator = DoubleValidator()
self.unit = unit
if isinstance(value, units.Quantity):
if self.unit is None:
self.set_quantity(value)
else:
self.set_quantity_checked(value)
else:
self.set_magnitude(value)
config.number.changed.connect(self.updateEdit)
def _validate_value(self, text, pos):
if not text or self.validator is None:
return Acceptable, text, pos
self.validator.minimum = self.minimum
self.validator.maximum = self.minimum
return self.validator.validate(text, pos)
[docs] def parse(self, text):
return float(text)
@cachedproperty
def fmtspec(self):
return config.number.fmtspec
# own methods
[docs] def get_magnitude(self):
return self.value
[docs] def set_magnitude(self, magnitude):
self.value = magnitude
[docs] def get_unit(self):
return self._unit
[docs] def set_unit(self, unit):
self._unit = unit
self.suffix = "" if unit is None else " " + get_raw_label(unit)
self.placeholder_text = self.suffix
self.updateEdit()
[docs] def get_quantity(self):
magnitude = self.magnitude
unit = self.unit
if magnitude is None or unit is None:
return magnitude
return magnitude * unit
[docs] def set_quantity(self, value):
self.set_unit(get_unit(value))
self.set_magnitude(value.magnitude)
magnitude = property(get_magnitude, set_magnitude)
quantity = property(get_quantity, set_quantity)
unit = property(get_unit, set_unit)
# set magnitude/unit/quantity and check units
[docs] def set_quantity_checked(self, value):
scaled = tounit(value, self.unit)
self.set_magnitude(scaled.magnitude)
[docs]class QuantityDisplay(QuantityControlBase, QLineEdit):
"""
Readonly line-edit showing a quantity.
"""
valueChanged = Signal(object)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setAlignment(Qt.AlignRight)
self.setReadOnly(True)
self.selectionChanged.connect(self.clear_selectall_pending)
[docs] def line_edit(self):
return self
# TODO: make this work for SpinBox as well
_selectall_pending = False
[docs] def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self._selectall_pending = True
super().mousePressEvent(event)
[docs] def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
if self._selectall_pending:
self._selectall_pending = False
self.selectAll()
super().mouseReleaseEvent(event)
[docs] def clear_selectall_pending(self):
self._selectall_pending = False