From 6e83a0e58f72e88efa1713abcd8cf9098edbe0b8 Mon Sep 17 00:00:00 2001 From: Per A Brodtkorb Date: Sun, 21 Nov 2021 19:18:08 +0100 Subject: [PATCH] Refactored the RangeEditor backend for Qt and Wx so that their behavior becomes more consistent. (Closes issue #1771) Updated the key_click_text_entry function in wx/_interaction_helpers.py to respond to "Right" or "Left" interaction. (Closes issue #1772) Replaced the use of possibly insecure eval function with the safer ast.literal_eval in RangeEditor. (See issue #1773) Fixed a bug on wx where the text style RangeEditor did raise an error whenever the textbox was empty, even if enter was not pressed. Changed the default low and high attribute of RangeEditor from 0, 1 to Undefined, so that the limits are from -infinite to +infinite by default. (Closes issue #1775) Updated SimpleSpinEditor (for both Wx and PyQt) to allow floats and be able to specify the step of the spin control as well as allow low and/or high to be Undefined. Also added the possibility to increase the step value for the spinner by the use of the modifier keys "Shift", "Ctrl" or "Alt". They will increase the step value for the spinner by a factor of 2, 10 and 100, respectively. If a combination of "Shift", "Ctrl", "Alt" -keys are pressed the step increment will be the product of their factors. (Closes issue #1776) Registered the interaction helper for SimpleSpinEditor for both Qt and Wx: (traitsui/testing/tester/_ui_tester_registry/qt4/_traitsui/range_editor.py, traitsui/testing/tester/_ui_tester_registry/wx/_traitsui/range_editor.py) Made the behavior of LargeRangeSliderEditor more userfriendly. It now allows low and/or high to be Undefined. Its increase/decrease method also increase/decrease the trait value of a factor 10 so that the value remains the same if the increase range button is pushed once followed by a push on the decrease range button (given that the value did not exceed the boundaries low or high). (Closes issue #1777) Added a show_error_dialog attribute to the EditorFactory class in editor_factory.py, which will disable the error popup dialog when set to False. Set the show_error_dialog attribute to False in RangeEditorDemo.py and test_range_editor_text.py in order to avoid that the popup error dialog blocks and eventually makes the tests hang indefinitely. (Closes issue #1778) Changed the default behavior of mode attribute in the RangeEditor. Now the the 'View' conforms to the view explicitly set by the mode attribute unless it is set to 'auto'. Eg. if the mode is set to 'spinner' you will always get a SimpleSpinEditor as view. (Closes issue #1779) Updated the mouse_click function in wx/_interaction_helpers.py to set focus to current control. (Closes issue #1780) --- traitsui/editor.py | 4 +- traitsui/editor_factory.py | 3 + traitsui/editors/range_editor.py | 23 +- .../demo/Standard_Editors/RangeEditor_demo.py | 40 +- .../tests/test_RangeEditor_demo.py | 9 +- traitsui/qt4/editor.py | 34 +- traitsui/qt4/range_editor.py | 987 +++++++------- .../qt4/_traitsui/range_editor.py | 14 +- .../wx/_interaction_helpers.py | 25 +- .../wx/_traitsui/range_editor.py | 14 +- traitsui/tests/editors/test_range_editor.py | 71 +- .../tests/editors/test_range_editor_text.py | 103 +- traitsui/tests/test_editor.py | 5 +- traitsui/wx/editor.py | 18 +- traitsui/wx/range_editor.py | 1162 +++++++---------- 15 files changed, 1203 insertions(+), 1309 deletions(-) diff --git a/traitsui/editor.py b/traitsui/editor.py index 4aaab2850..cd6781ef0 100644 --- a/traitsui/editor.py +++ b/traitsui/editor.py @@ -44,6 +44,7 @@ from .item import Item +UNITTESTING = False # Reference to an EditorFactory object factory_trait = Instance(EditorFactory) @@ -164,7 +165,8 @@ def error(self, excp): excp : Exception The exception which occurred. """ - pass + if UNITTESTING: + raise excp def set_focus(self): """Assigns focus to the editor's underlying toolkit widget. diff --git a/traitsui/editor_factory.py b/traitsui/editor_factory.py index 8c971aab5..7cae4d579 100644 --- a/traitsui/editor_factory.py +++ b/traitsui/editor_factory.py @@ -76,6 +76,9 @@ class EditorFactory(HasPrivateTraits): #: The editor class to use for 'readonly' style views. readonly_editor_class = Property() + #: Show the error dialog when an error occur. + show_error_dialog = Bool(True) + def __init__(self, *args, **traits): """Initializes the factory object.""" HasPrivateTraits.__init__(self, **traits) diff --git a/traitsui/editors/range_editor.py b/traitsui/editors/range_editor.py index 832344298..d9fd6621a 100644 --- a/traitsui/editors/range_editor.py +++ b/traitsui/editors/range_editor.py @@ -10,6 +10,7 @@ """ Defines the range editor factory for all traits user interface toolkits. """ +import ast import warnings from types import CodeType @@ -130,12 +131,6 @@ def init(self, handler=None): self.high = eval(handler._high) else: self.high = handler._high - else: - if (self.low is None) and (self.low_name == ""): - self.low = 0.0 - - if (self.high is None) and (self.high_name == ""): - self.high = 1.0 def _get_low(self): return self._low @@ -217,9 +212,9 @@ def _get_simple_editor_class(self): The type of editor depends on the type and extent of the range being edited: - * One end of range is unspecified: RangeTextEditor * **mode** is specified and not 'auto': editor corresponding to **mode** + * One end of range is unspecified: RangeTextEditor * Floating point range with extent > 100: LargeRangeSliderEditor * Integer range or floating point range with extent <= 100: SimpleSliderEditor @@ -227,15 +222,15 @@ def _get_simple_editor_class(self): """ low, high, is_float = self._low_value, self._high_value, self.is_float + if self.mode != "auto": + return toolkit_object("range_editor:SimpleEditorMap")[self.mode] + if (low is None) or (high is None): return toolkit_object("range_editor:RangeTextEditor") if (not is_float) and (abs(high - low) > 1000000000): return toolkit_object("range_editor:RangeTextEditor") - if self.mode != "auto": - return toolkit_object("range_editor:SimpleEditorMap")[self.mode] - if is_float and (abs(high - low) > 100): return toolkit_object("range_editor:LargeRangeSliderEditor") @@ -250,21 +245,21 @@ def _get_custom_editor_class(self): The type of editor depends on the type and extent of the range being edited: - * One end of range is unspecified: RangeTextEditor * **mode** is specified and not 'auto': editor corresponding to **mode** + * One end of range is unspecified: RangeTextEditor * Floating point range: Same as "simple" style * Integer range with extent > 15: Same as "simple" style * Integer range with extent <= 15: CustomEnumEditor """ low, high, is_float = self._low_value, self._high_value, self.is_float - if (low is None) or (high is None): - return toolkit_object("range_editor:RangeTextEditor") - if self.mode != "auto": return toolkit_object("range_editor:CustomEditorMap")[self.mode] + if (low is None) or (high is None): + return toolkit_object("range_editor:RangeTextEditor") + if is_float or (abs(high - low) > 15): return self.simple_editor_class diff --git a/traitsui/examples/demo/Standard_Editors/RangeEditor_demo.py b/traitsui/examples/demo/Standard_Editors/RangeEditor_demo.py index 59b9cd778..01a9b78d3 100644 --- a/traitsui/examples/demo/Standard_Editors/RangeEditor_demo.py +++ b/traitsui/examples/demo/Standard_Editors/RangeEditor_demo.py @@ -28,18 +28,48 @@ .. _RangeEditor API docs: https://docs.enthought.com/traitsui/api/traitsui.editors.range_editor.html#traitsui.editors.range_editor.RangeEditor """ -from traits.api import HasTraits, Range +from traits.api import HasTraits, Range as _Range from traitsui.api import Item, Group, View +# TODO: Update traits.api.Range with the following in order to avoid the popup-error-dialog. +# TODO: Remove redefinition of Range here once the traits.api.Range is updated. +class Range(_Range): + def create_editor(self): + """ Returns the default UI editor for the trait. + """ + # fixme: Needs to support a dynamic range editor. + + auto_set = self.auto_set + if auto_set is None: + auto_set = True + show_error_dialog = self.show_error_dialog + if show_error_dialog is None: + show_error_dialog = True + + from traitsui.api import RangeEditor + return RangeEditor( + self, + mode=self.mode or "auto", + cols=self.cols or 3, + auto_set=auto_set, + enter_set=self.enter_set or False, + low_label=self.low or "", + high_label=self.high or "", + low_name=self._low_name, + high_name=self._high_name, + show_error_dialog=show_error_dialog + ) + + class RangeEditorDemo(HasTraits): """Defines the RangeEditor demo class.""" # Define a trait for each of four range variants: - small_int_range = Range(1, 16) - medium_int_range = Range(1, 25) - large_int_range = Range(1, 150) - float_range = Range(0.0, 150.0) + small_int_range = Range(1, 16, show_error_dialog=False) + medium_int_range = Range(1, 25, show_error_dialog=False) + large_int_range = Range(1, 150, show_error_dialog=False) + float_range = Range(0.0, 150.0, show_error_dialog=False) # RangeEditor display for narrow integer Range traits (< 17 wide): int_range_group1 = Group( diff --git a/traitsui/examples/demo/Standard_Editors/tests/test_RangeEditor_demo.py b/traitsui/examples/demo/Standard_Editors/tests/test_RangeEditor_demo.py index ebf90266d..5a225cfff 100644 --- a/traitsui/examples/demo/Standard_Editors/tests/test_RangeEditor_demo.py +++ b/traitsui/examples/demo/Standard_Editors/tests/test_RangeEditor_demo.py @@ -129,7 +129,8 @@ def test_run_demo(self): simple_float_slider.perform(KeyClick("Page Up")) self.assertEqual(demo.float_range, 1.000) simple_float_text = simple_float.locate(Textbox()) - for _ in range(3): + displayed = simple_float_text.inspect(DisplayedText()) + for _ in range(len(displayed) - 2): simple_float_text.perform(KeyClick("Backspace")) simple_float_text.perform(KeyClick("5")) simple_float_text.perform(KeyClick("Enter")) @@ -137,10 +138,10 @@ def test_run_demo(self): custom_float_slider = custom_float.locate(Slider()) # after the trait is set to 1.5 above, the active range shown by - # the LargeRangeSliderEditor for the custom style is [0,11.500] - # so a page down is now a decrement of 1.15 + # the LargeRangeSliderEditor for the custom style is [0,10.00] + # so a page down is now a decrement of 1.0 custom_float_slider.perform(KeyClick("Page Down")) - self.assertEqual(round(demo.float_range, 2), 0.35) + self.assertEqual(round(demo.float_range, 2), 0.5) custom_float_text = custom_float.locate(Textbox()) for _ in range(5): custom_float_text.perform(KeyClick("Backspace")) diff --git a/traitsui/qt4/editor.py b/traitsui/qt4/editor.py index a73e65158..bd17eabfd 100644 --- a/traitsui/qt4/editor.py +++ b/traitsui/qt4/editor.py @@ -67,22 +67,24 @@ def update_editor(self): def error(self, excp): """Handles an error that occurs while setting the object's trait value.""" - # Make sure the control is a widget rather than a layout. - if isinstance(self.control, QtGui.QLayout): - control = self.control.parentWidget() - else: - control = self.control - - message_box = QtGui.QMessageBox( - QtGui.QMessageBox.Information, - self.description + " value error", - str(excp), - buttons=QtGui.QMessageBox.Ok, - parent=control, - ) - message_box.setTextFormat(QtCore.Qt.PlainText) - message_box.setEscapeButton(QtGui.QMessageBox.Ok) - message_box.exec_() + super().error(excp) + if self.factory.show_error_dialog: + # Make sure the control is a widget rather than a layout. + if isinstance(self.control, QtGui.QLayout): + control = self.control.parentWidget() + else: + control = self.control + + message_box = QtGui.QMessageBox( + QtGui.QMessageBox.Information, + self.description + " value error", + str(excp), + buttons=QtGui.QMessageBox.Ok, + parent=control, + ) + message_box.setTextFormat(QtCore.Qt.PlainText) + message_box.setEscapeButton(QtGui.QMessageBox.Ok) + message_box.exec_() def set_tooltip_text(self, control, text): """Sets the tooltip for a specified control.""" diff --git a/traitsui/qt4/range_editor.py b/traitsui/qt4/range_editor.py index d247e894a..249adb27f 100644 --- a/traitsui/qt4/range_editor.py +++ b/traitsui/qt4/range_editor.py @@ -25,17 +25,16 @@ """ +import ast from math import log10 from pyface.qt import QtCore, QtGui from traits.api import TraitError, Str, Float, Any, Bool -from .editor_factory import TextEditor - from .editor import Editor -from .constants import OKColor, ErrorColor +from .constants import OKColor from .helper import IconButton @@ -62,31 +61,27 @@ def _set_value(self, value): value = self.evaluate(value) Editor._set_value(self, value) - -class SimpleSliderEditor(BaseRangeEditor): - """Simple style of range editor that displays a slider and a text field. - - The user can set a value either by moving the slider or by typing a value - in the text field. - """ - - # ------------------------------------------------------------------------- - # Trait definitions: - # ------------------------------------------------------------------------- - - #: Low value for the slider range + #: Low value for the range low = Any() - #: High value for the slider range + #: High value for the range high = Any() #: Deprecated: This trait is no longer used. See enthought/traitsui#1704 format = Str() + #: Flag indicating that the UI is in the process of being updated + ui_changing = Bool(False) + def init(self, parent): """Finishes initializing the editor by creating the underlying toolkit widget. """ + self._init_with_factory_defaults() + self.control = self._make_control() + self._do_layout(self.control) + + def _init_with_factory_defaults(self): factory = self.factory if not factory.low_name: self.low = factory.low @@ -100,81 +95,59 @@ def init(self, parent): self.sync_value(factory.low_name, "low", "from") self.sync_value(factory.high_name, "high", "from") - self.control = QtGui.QWidget() - panel = QtGui.QHBoxLayout(self.control) - panel.setContentsMargins(0, 0, 0, 0) + def _make_control(self): + raise NotImplementedError - fvalue = self.value + def _do_layout(self, control): + raise NotImplementedError + + def _clip(self, fvalue, low, high): + """Returns fvalue clipped between low and high""" try: - if not (self.low <= fvalue <= self.high): - fvalue = self.low - fvalue_text = self.string_value(fvalue) + if low is not None and fvalue < low: + return low + if high is not None and high < fvalue: + return high except: - fvalue_text = "" - fvalue = self.low - - ivalue = self._convert_to_slider(fvalue) - - self._label_lo = QtGui.QLabel() - self._label_lo.setAlignment( - QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter - ) - if factory.label_width > 0: - self._label_lo.setMinimumWidth(factory.label_width) - panel.addWidget(self._label_lo) + return low + return fvalue - self.control.slider = slider = QtGui.QSlider(QtCore.Qt.Horizontal) - slider.setTracking(factory.auto_set) - slider.setMinimum(0) - slider.setMaximum(10000) - slider.setPageStep(1000) - slider.setSingleStep(100) - slider.setValue(ivalue) - slider.valueChanged.connect(self.update_object_on_scroll) - panel.addWidget(slider) + def _make_text_entry(self, fvalue_text): - self._label_hi = QtGui.QLabel() - panel.addWidget(self._label_hi) - if factory.label_width > 0: - self._label_hi.setMinimumWidth(factory.label_width) + text = QtGui.QLineEdit(fvalue_text) + # text.installEventFilter(text) + if self.factory.enter_set: + text.returnPressed.connect(self.update_object_on_enter) - self.control.text = text = QtGui.QLineEdit(fvalue_text) text.editingFinished.connect(self.update_object_on_enter) + if self.factory.auto_set: + text.textChanged.connect(self.update_object_on_enter) # The default size is a bit too big and probably doesn't need to grow. sh = text.sizeHint() sh.setWidth(sh.width() // 2) text.setMaximumSize(sh) - - panel.addWidget(text) - - low_label = factory.low_label - if factory.low_name != "": - low_label = self.string_value(self.low) - - high_label = factory.high_label - if factory.high_name != "": - high_label = self.string_value(self.high) - - self._label_lo.setText(low_label) - self._label_hi.setText(high_label) - - self.set_tooltip(slider) - self.set_tooltip(self._label_lo) - self.set_tooltip(self._label_hi) self.set_tooltip(text) - def update_object_on_scroll(self, pos): - """Handles the user changing the current slider value.""" - value = self._convert_from_slider(pos) - self.control.text.setText(self.string_value(value)) - try: - self.value = value - except Exception as exc: - from traitsui.api import raise_to_debug + return text - raise_to_debug() + def _validate(self, value): + if self.low is not None and value < self.low: + message = "The value ({}) must be larger than {}!" + raise ValueError(message.format(value, self.low)) + if self.high is not None and value > self.high: + message = "The value ({}) must be smaller than {}!" + raise ValueError(message.format(value, self.high)) + if not self.factory.is_float and isinstance(value, float): + message = "The value must be an integer, but a value of {} was specified." + raise ValueError(message.format(value)) + + def _set_color(self, color): + if self.control is not None: + pal = QtGui.QPalette(self.control.text.palette()) + pal.setColor(QtGui.QPalette.Base, color) + self.control.text.setPalette(pal) def update_object_on_enter(self): """Handles the user pressing the Enter key in the text field.""" @@ -184,54 +157,49 @@ def update_object_on_enter(self): return try: - try: - value = eval(str(self.control.text.text()).strip()) - except Exception as ex: - # The entered something that didn't eval as a number, (e.g., - # 'foo') pretend it didn't happen - value = self.value - self.control.text.setText(str(value)) - # for compound editor, value may be non-numeric - if not isinstance(value, (int, float)): - return - - if not self.factory.is_float: - value = int(value) - + value = ast.literal_eval(self.control.text.text()) + self._validate(value) self.value = value - blocked = self.control.slider.blockSignals(True) - try: - self.control.slider.setValue( - self._convert_to_slider(self.value) - ) - finally: - self.control.slider.blockSignals(blocked) - except TraitError as excp: - pass + except Exception as excp: + self.error(excp) + return + + if not self.ui_changing: + self._set_slider(value) + + self._set_color(OKColor) + if self._error is not None: + self._error = None + self.ui.errors -= 1 + + def error(self, excp): + """ Handles an error that occurs while setting the object's trait value. + """ + if self._error is None: + self._error = True + self.ui.errors += 1 + super().error(excp) + self.set_error_state(True) def update_editor(self): """Updates the editor when the object trait changes externally to the editor. """ - value = self.value - low = self.low - high = self.high - try: - text = self.string_value(value) - 1 / (low <= value <= high) - except: - text = "" - value = low - - ivalue = self._convert_to_slider(value) + fvalue = self._clip(self.value, self.low, self.high) + text = self.string_value(fvalue) + self.ui_changing = True self.control.text.setText(text) + self.ui_changing = False + self._set_slider(fvalue) - blocked = self.control.slider.blockSignals(True) - try: - self.control.slider.setValue(ivalue) - finally: - self.control.slider.blockSignals(blocked) + def _set_slider(self, value): + """Updates the slider range controls.""" + # Do nothing for non-sliders. + + def _get_current_range(self): + low, high = self.low, self.high + return low, high def get_error_control(self): """Returns the editor's control for indicating error status.""" @@ -239,44 +207,137 @@ def get_error_control(self): def _low_changed(self, low): if self.value < low: - if self.factory.is_float: - self.value = float(low) - else: - self.value = int(low) - - if self._label_lo is not None: - self._label_lo.setText(self.string_value(low)) + self.value = float(low) if self.factory.is_float else int(low) + if self.control is not None: self.update_editor() def _high_changed(self, high): if self.value > high: - if self.factory.is_float: - self.value = float(high) - else: - self.value = int(high) - - if self._label_hi is not None: - self._label_hi.setText(self.string_value(high)) + self.value = float(high) if self.factory.is_float else int(high) + if self.control is not None: self.update_editor() + +class SimpleSliderEditor(BaseRangeEditor): + """ Simple style of range editor that displays a slider and a text field. + + The user can set a value either by moving the slider or by typing a value + in the text field. + """ + + # ------------------------------------------------------------------------- + # Trait definitions: See BaseRangeEditor + # ------------------------------------------------------------------------- + + def _make_control(self): + + low, high = self._get_current_range() + fvalue = self._clip(self.value, low, high) + fvalue_text = self.string_value(fvalue) + + width = self._get_default_width() + + control = QtGui.QWidget() + control.label_lo = self._make_label_low(low, width) + control.slider = self._make_slider(fvalue) + control.label_hi = self._make_label_high(high, width) + control.text = self._make_text_entry(fvalue_text) + return control + + @staticmethod + def _do_layout(control): + layout = QtGui.QHBoxLayout(control) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(control.label_lo) + layout.addWidget(control.slider) + layout.addWidget(control.label_hi) + layout.addWidget(control.text) + + def _get_default_width(self): + return self.factory.label_width + + def _get_label_high(self, high): + if self.factory.high_name != "": + return self.string_value(high) + return self.factory.high_label + + def _get_label_low(self, low): + if self.factory.low_name != "": + return self.string_value(low) + return self.factory.low_label + + def _make_label_low(self, low, width): + low_label = self._get_label_low(low) + label_lo = QtGui.QLabel(low_label) + label_lo.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + if width > 0: + label_lo.setMinimumWidth(width) + self.set_tooltip(label_lo) + + return label_lo + + def _make_slider(self, fvalue): + ivalue = self._convert_to_slider(fvalue) + slider = QtGui.QSlider(QtCore.Qt.Horizontal) + slider.setTracking(self.factory.auto_set) + slider.setMinimum(0) + slider.setMaximum(10000) + slider.setPageStep(1000) + slider.setSingleStep(100) + slider.setValue(ivalue) + slider.valueChanged.connect(self.update_object_on_scroll) + self.set_tooltip(slider) + + return slider + + def _make_label_high(self, high, width): + high_label = self._get_label_high(high) + label_hi = QtGui.QLabel(high_label) + if width > 0: + label_hi.setMinimumWidth(width) + self.set_tooltip(label_hi) + + return label_hi + + def update_object_on_scroll(self, pos): + """Handles the user changing the current slider value.""" + value = self._convert_from_slider(pos) + try: + self.ui_changing = True + self.control.text.setText(self.string_value(value)) + self.value = value + except TraitError: + pass + finally: + self.ui_changing = False + + def _set_slider(self, value): + """Updates the slider range controls.""" + low, high = self._get_current_range() + self.control.label_lo.setText(self.string_value(low)) + self.control.label_hi.setText(self.string_value(high)) + blocked = self.control.slider.blockSignals(True) + try: + ivalue = self._convert_to_slider(value) + self.control.slider.setValue(ivalue) + finally: + self.control.slider.blockSignals(blocked) + def _convert_to_slider(self, value): """Returns the slider setting corresponding to the user-supplied value.""" - if self.high > self.low: - ivalue = int( - (float(value - self.low) / (self.high - self.low)) * 10000.0 - ) - else: - ivalue = self.low - - if ivalue is None: - ivalue = 0 - return ivalue + low, high = self._get_current_range() + if high > low: + return int(float(value - low) / (high - low) * 10000.0) + if low is None: + return 0 + return low def _convert_from_slider(self, ivalue): """Returns the float or integer value corresponding to the slider setting. """ - value = self.low + ((float(ivalue) / 10000.0) * (self.high - self.low)) + low, high = self._get_current_range() + value = low + ((float(ivalue) / 10000.0) * (high - low)) if not self.factory.is_float: value = int(round(value)) return value @@ -289,448 +350,340 @@ class LogRangeSliderEditor(SimpleSliderEditor): def _convert_to_slider(self, value): """Returns the slider setting corresponding to the user-supplied value.""" - value = max(value, self.low) - ivalue = int( - (log10(value) - log10(self.low)) - / (log10(self.high) - log10(self.low)) - * 10000.0 - ) - return ivalue + low, high = self._get_current_range() + value = max(value, low) + return int((log10(value) - log10(low)) / (log10(high) - log10(low)) * 10000.0) def _convert_from_slider(self, ivalue): """Returns the float or integer value corresponding to the slider setting. """ - value = float(ivalue) / 10000.0 * (log10(self.high) - log10(self.low)) + low, high = self._get_current_range() + value = float(ivalue) / 10000.0 * (log10(high) - log10(low)) # Do this to handle floating point errors, where fvalue may exceed # self.high. - fvalue = min(self.low * 10 ** (value), self.high) + fvalue = min(low * 10 ** (value), high) if not self.factory.is_float: fvalue = int(round(fvalue)) return fvalue -class LargeRangeSliderEditor(BaseRangeEditor): +class LargeRangeSliderEditor(SimpleSliderEditor): """A slider editor for large ranges. - The editor displays a slider and a text field. A subset of the total range - is displayed in the slider; arrow buttons at each end of the slider let - the user move the displayed range higher or lower. + The editor displays a slider and a text field. A subset of the total + range is displayed in the slider; arrow buttons at each end of the + slider let the user move the displayed range higher or lower. """ # ------------------------------------------------------------------------- - # Trait definitions: + # Trait definitions: See BaseRangeEditor # ------------------------------------------------------------------------- - #: Low value for the slider range - low = Any(0) - - #: High value for the slider range - high = Any(1) - - #: Low end of displayed range + #: Low end of displayed slider range cur_low = Float() - #: High end of displayed range + #: High end of displayed slider range cur_high = Float() - #: Flag indicating that the UI is in the process of being updated - ui_changing = Bool(False) - def init(self, parent): """Finishes initializing the editor by creating the underlying toolkit widget. """ - factory = self.factory - - # Initialize using the factory range defaults: - self.low = factory.low - self.high = factory.high - self.evaluate = factory.evaluate - - # Hook up the traits to listen to the object. - self.sync_value(factory.low_name, "low", "from") - self.sync_value(factory.high_name, "high", "from") - self.sync_value(factory.evaluate_name, "evaluate", "from") - - self.init_range() - low = self.cur_low - high = self.cur_high - - self._set_format() - - self.control = QtGui.QWidget() - panel = QtGui.QHBoxLayout(self.control) - panel.setContentsMargins(0, 0, 0, 0) - - fvalue = self.value - - try: - fvalue_text = self._format % fvalue - 1 / (low <= fvalue <= high) - except: - fvalue_text = "" - fvalue = low - - if high > low: - ivalue = int((float(fvalue - low) / (high - low)) * 10000) - else: - ivalue = low - - # Lower limit label: - self.control.label_lo = label_lo = QtGui.QLabel() - label_lo.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - panel.addWidget(label_lo) - - # Lower limit button: - self.control.button_lo = IconButton( - QtGui.QStyle.SP_ArrowLeft, self.reduce_range - ) - panel.addWidget(self.control.button_lo) - - # Slider: - self.control.slider = slider = QtGui.QSlider(QtCore.Qt.Horizontal) - slider.setTracking(factory.auto_set) - slider.setMinimum(0) - slider.setMaximum(10000) - slider.setPageStep(1000) - slider.setSingleStep(100) - slider.setValue(ivalue) - slider.valueChanged.connect(self.update_object_on_scroll) - panel.addWidget(slider) - - # Upper limit button: - self.control.button_hi = IconButton( - QtGui.QStyle.SP_ArrowRight, self.increase_range - ) - panel.addWidget(self.control.button_hi) - - # Upper limit label: - self.control.label_hi = label_hi = QtGui.QLabel() - panel.addWidget(label_hi) - - # Text entry: - self.control.text = text = QtGui.QLineEdit(fvalue_text) - text.editingFinished.connect(self.update_object_on_enter) - - # The default size is a bit too big and probably doesn't need to grow. - sh = text.sizeHint() - sh.setWidth(sh.width() // 2) - text.setMaximumSize(sh) - - panel.addWidget(text) - - label_lo.setText(str(low)) - label_hi.setText(str(high)) - self.set_tooltip(slider) - self.set_tooltip(label_lo) - self.set_tooltip(label_hi) - self.set_tooltip(text) - - # Update the ranges and button just in case. - self.update_range_ui() - - def update_object_on_scroll(self, pos): - """Handles the user changing the current slider value.""" - value = self.cur_low + ( - (float(pos) / 10000.0) * (self.cur_high - self.cur_low) - ) - - self.control.text.setText(self._format % value) - - if self.factory.is_float: - self.value = value - else: - self.value = int(value) - - def update_object_on_enter(self): - """Handles the user pressing the Enter key in the text field.""" - # It is possible the event is processed after the control is removed - # from the editor - if self.control is None: - return - try: - self.value = eval(str(self.control.text.text()).strip()) - except TraitError as excp: - pass - - def update_editor(self): - """Updates the editor when the object trait changes externally to the - editor. - """ - value = self.value - low = self.low - high = self.high - try: - text = self._format % value - 1 / (low <= value <= high) - except: - value = low - self.value = value - - if not self.ui_changing: - self.init_range() - self.update_range_ui() - - def update_range_ui(self): + self._init_with_factory_defaults() + self.init_current_range(self.value) + self.control = self._make_control(parent) + # Set-up the layout: + self._do_layout(self.control) + + def _make_control(self, parent): + control = super()._make_control() + low, high = self._get_current_range() + + control.button_lo = self._make_button_low(low) + control.button_hi = self._make_button_high(high) + return control + + @staticmethod + def _do_layout(control): + layout = QtGui.QHBoxLayout(control) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(control.label_lo) + layout.addWidget(control.button_lo) + layout.addWidget(control.slider) + layout.addWidget(control.button_hi) + layout.addWidget(control.label_hi) + layout.addWidget(control.text) + + def _make_button_low(self, low): + button_lo = IconButton(QtGui.QStyle.SP_ArrowLeft, self.reduce_range) + button_lo.setEnabled(self.low is None or low != self.low) + return button_lo + + def _make_button_high(self, high): + button_hi = IconButton(QtGui.QStyle.SP_ArrowRight, self.increase_range) + button_hi.setEnabled(self.high is None or high != self.high) + return button_hi + + def _set_slider(self, value): """Updates the slider range controls.""" - low, high = self.cur_low, self.cur_high - value = self.value - self._set_format() - self.control.label_lo.setText(self._format % low) - self.control.label_hi.setText(self._format % high) - - if high > low: - ivalue = int((float(value - low) / (high - low)) * 10000.0) - else: - ivalue = low - + low, high = self._get_current_range() + if not low <= value <= high: + low, high = self.init_current_range(value) + ivalue = self._convert_to_slider(value) blocked = self.control.slider.blockSignals(True) - self.control.slider.setValue(ivalue) - self.control.slider.blockSignals(blocked) + try: + self.control.slider.setValue(ivalue) + finally: + self.control.slider.blockSignals(blocked) - text = self._format % self.value - self.control.text.setText(text) - self.control.button_lo.setEnabled(low != self.low) - self.control.button_hi.setEnabled(high != self.high) + fmt = self._get_format() + self.control.label_lo.setText(fmt % low) + self.control.label_hi.setText(fmt % high) - def init_range(self): - """Initializes the slider range controls.""" - value = self.value + self.control.button_lo.setEnabled(self.low is None or low != self.low) + self.control.button_hi.setEnabled(self.high is None or high != self.high) + + def init_current_range(self, value): + """Initializes the current slider range controls, cur_low and cur_high.""" low, high = self.low, self.high - if (high is None) and (low is not None): - high = -low - mag = abs(value) - if mag <= 10.0: - cur_low = max(value - 10, low) - cur_high = min(value + 10, high) - else: - d = 0.5 * (10 ** int(log10(mag) + 1)) - cur_low = max(low, value - d) - cur_high = min(high, value + d) + mag = max(abs(value), 1) + rounded_value = 10 ** int(log10(mag)) + fact_hi, fact_lo = (10, 1) if value >= 0 else (-1, -10) + cur_low = rounded_value * fact_lo + cur_high = rounded_value * fact_hi + if mag <= 10: + if value >= 0: + cur_low *= -1 + else: + cur_high *= -1 + + if low is not None and cur_low < low: + cur_low = low + if high is not None and high < cur_high: + cur_high = high self.cur_low, self.cur_high = cur_low, cur_high + return self.cur_low, self.cur_high def reduce_range(self): """Reduces the extent of the displayed range.""" - low, high = self.low, self.high + value = self.value + low = self.low + + old_cur_low = self.cur_low if abs(self.cur_low) < 10: - self.cur_low = max(-10, low) - self.cur_high = min(10, high) - elif self.cur_low > 0: - self.cur_high = self.cur_low - self.cur_low = max(low, self.cur_low / 10) + value = value - 10 + self.cur_low = max(-10, low) if low is not None else -10 + if old_cur_low - self.cur_low > 9: + self.cur_high = old_cur_low else: - self.cur_high = self.cur_low - self.cur_low = max(low, self.cur_low * 10) + fact = 0.1 if self.cur_low > 0 else 10 + value = value * fact + new_cur_low = self.cur_low * fact + self.cur_low = max(low, new_cur_low) if low is not None else new_cur_low + if self.cur_low == new_cur_low: + self.cur_high = old_cur_low - self.ui_changing = True - self.value = min(max(self.value, self.cur_low), self.cur_high) - self.ui_changing = False - self.update_range_ui() + value = min(max(value, self.cur_low), self.cur_high) + + if self.factory.is_float is False: + value = int(value) + self.value = value + self.update_editor() def increase_range(self): """Increased the extent of the displayed range.""" - low, high = self.low, self.high + value = self.value + high = self.high + old_cur_high = self.cur_high if abs(self.cur_high) < 10: - self.cur_low = max(-10, low) - self.cur_high = min(10, high) - elif self.cur_high > 0: - self.cur_low = self.cur_high - self.cur_high = min(high, self.cur_high * 10) + value = value + 10 + self.cur_high = min(10, high) if high is not None else 10 + if self.cur_high - old_cur_high > 9: + self.cur_low = old_cur_high else: - self.cur_low = self.cur_high - self.cur_high = min(high, self.cur_high / 10) - - self.ui_changing = True - self.value = min(max(self.value, self.cur_low), self.cur_high) - self.ui_changing = False - self.update_range_ui() - - def _set_format(self): - self._format = "%d" - factory = self.factory - low, high = self.cur_low, self.cur_high - diff = high - low - if factory.is_float: - if diff > 99999: - self._format = "%.2g" - elif diff > 1: - self._format = "%%.%df" % max(0, 4 - int(log10(high - low))) - else: - self._format = "%.3f" + fact = 10 if self.cur_high > 0 else 0.1 + value = value * fact + new_cur_high = self.cur_high * fact + self.cur_high = min(high, new_cur_high) if high is not None else new_cur_high + if self.cur_high == new_cur_high: + self.cur_low = old_cur_high - def get_error_control(self): - """Returns the editor's control for indicating error status.""" - return self.control.text + value = min(max(value, self.cur_low), self.cur_high) - def _low_changed(self, low): - if self.control is not None: - if self.value < low: - if self.factory.is_float: - self.value = float(low) - else: - self.value = int(low) + if self.factory.is_float is False: + value = int(value) - self.update_editor() + self.value = value + self.update_editor() - def _high_changed(self, high): - if self.control is not None: - if self.value > high: - if self.factory.is_float: - self.value = float(high) - else: - self.value = int(high) + def _get_format(self): + if self.factory.is_float: + low, high = self._get_current_range() + diff = high - low + if diff > 99999: + return "%.2g" + elif diff > 1: + return "%%.%df" % max(0, 4 - int(log10(diff))) + return "%.3f" + return "%d" - self.update_editor() + def _get_current_range(self): + return self.cur_low, self.cur_high class SimpleSpinEditor(BaseRangeEditor): - """A simple style of range editor that displays a spin box control.""" - - # ------------------------------------------------------------------------- - # Trait definitions: - # ------------------------------------------------------------------------- - - # Low value for the slider range - low = Any() - - # High value for the slider range - high = Any() - - def init(self, parent): - """Finishes initializing the editor by creating the underlying toolkit - widget. - """ - factory = self.factory - if not factory.low_name: - self.low = factory.low - - if not factory.high_name: - self.high = factory.high - - self.sync_value(factory.low_name, "low", "from") - self.sync_value(factory.high_name, "high", "from") - low = self.low - high = self.high - - self.control = QtGui.QSpinBox() - self.control.setMinimum(low) - self.control.setMaximum(high) - self.control.setValue(self.value) - self.control.valueChanged.connect(self.update_object) - self.set_tooltip() - - def update_object(self, value): - """Handles the user selecting a new value in the spin box.""" - self._locked = True - try: - self.value = value - finally: - self._locked = False - - def update_editor(self): - """Updates the editor when the object trait changes externally to the - editor. - """ - if not self._locked: - blocked = self.control.blockSignals(True) - try: - self.control.setValue(int(self.value)) - except Exception: - from traitsui.api import raise_to_debug - - raise_to_debug() - finally: - self.control.blockSignals(blocked) - - def _low_changed(self, low): - if self.value < low: - if self.factory.is_float: - self.value = float(low) - else: - self.value = int(low) - - if self.control: - self.control.setMinimum(low) - self.control.setValue(int(self.value)) - - def _high_changed(self, high): - if self.value > high: - if self.factory.is_float: - self.value = float(high) - else: - self.value = int(high) - - if self.control: - self.control.setMaximum(high) - self.control.setValue(int(self.value)) - - -class RangeTextEditor(TextEditor): - """Editor for ranges that displays a text field. If the user enters a - value that is outside the allowed range, the background of the field - changes color to indicate an error. + """A simple style of range editor that displays a spin box control. + + The SimpleSpinEditor catches 3 different types of events that will increase/decrease + the value of the class: + 1) Spin event generated by pushing the up/down spinbutton; + 2) Key pressed event generated by pressing the arrow- or page-up/down of the keyboard. + 3) Mouse wheel event generated by rolling the mouse wheel up or down. + + In addition, there are some other functionalities: + + - ``Shift`` + arrow = 2 * increment (or ``Shift`` + mouse wheel); + - ``Ctrl`` + arrow = 10 * increment (or ``Ctrl`` + mouse wheel); + - ``Alt`` + arrow = 100 * increment (or ``Alt`` + mouse wheel); + - Combinations of ``Shift``, ``Ctrl``, ``Alt`` increment the + step value by the product of the factors; + - ``PgUp`` & ``PgDn`` = 10 * increment * the product of the ``Shift``, ``Ctrl``, ``Alt`` + factors; """ # ------------------------------------------------------------------------- - # Trait definitions: + # Trait definitions: See BaseRangeEditor # ------------------------------------------------------------------------- - #: Low value for the slider range - low = Any() - - #: High value for the slider range - high = Any() - - #: Function to evaluate floats/ints - evaluate = Any() - - def init(self, parent): - """Finishes initializing the editor by creating the underlying toolkit - widget. - """ - TextEditor.init(self, parent) - - factory = self.factory - if not factory.low_name: - self.low = factory.low - - if not factory.high_name: - self.high = factory.high - - self.evaluate = factory.evaluate - self.sync_value(factory.evaluate_name, "evaluate", "from") + #: Step value for the spinner + step = Any(1) + + def _make_control(self): + + low, high = self._get_current_range() + fvalue = self._clip(self.value, low, high) + fvalue_text = self.string_value(fvalue) + + spin_up_or_down = self._spin + + class Control(QtGui.QWidget): + def wheelEvent(self, event): + delta = event.angleDelta() + y = delta.y() + x = delta.x() + sign = 1 if x + y > 0 else -1 + spin_up_or_down(sign) + + def keyPressEvent(self, event): + key = event.key() + scale = 1 if key in {QtCore.Qt.Key_Up, QtCore.Qt.Key_Down} else 10 + if key in {QtCore.Qt.Key_Up, QtCore.Qt.Key_PageUp}: + spin_up_or_down(scale) + elif key in {QtCore.Qt.Key_Down, QtCore.Qt.Key_PageDown}: + spin_up_or_down(-scale) + + control = Control() + control.text = self._make_text_entry(fvalue_text) + control.button_lo = self._make_button_low(fvalue) + control.button_hi = self._make_button_high(fvalue) + return control + + @staticmethod + def _do_layout(control): + height = control.text.minimumSizeHint().height() + width = height // 2 - 1 + control.button_lo.setFixedSize(width, width) + control.button_hi.setFixedSize(width, width) + + layout = QtGui.QHBoxLayout(control) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(control.text) + vwidget = QtGui.QWidget() + vlayout = QtGui.QVBoxLayout(vwidget) + vlayout.setContentsMargins(0, 0, 0, 0) + vlayout.addWidget(control.button_hi) + vlayout.addWidget(control.button_lo) + layout.addWidget(vwidget) + # layout.addStretch(1) + # vwidget.resize(width, height) + + def _make_button_low(self, value): + # icon = QtGui.QStyle.SP_ArrowDown + icon = QtGui.QStyle.SP_TitleBarUnshadeButton + button_lo = IconButton(icon, self.spin_down) + button_lo.setEnabled(self.low is None or self.low < value) + return button_lo + + def _make_button_high(self, value): + #icon = QtGui.QStyle.SP_ArrowUp + icon = QtGui.QStyle.SP_TitleBarShadeButton + button_hi = IconButton(icon, self.spin_up) + button_hi.setEnabled(self.high is None or value < self.high) + return button_hi + + def _spin(self, sign): + step = sign * self._get_modifier() + value = self.value + low, high = self._get_current_range() - self.sync_value(factory.low_name, "low", "from") - self.sync_value(factory.high_name, "high", "from") + value = self._clip(value + step, low, high) - # force value to start in range - if not (self.low <= self.value <= self.high): - self.value = self.low + if self.factory.is_float is False: + value = int(value) + self.value = value + self.update_editor() + + def _get_modifier(self): + QModifiers = QtGui.QApplication.keyboardModifiers() + modifier = self.step + if (QModifiers & QtCore.Qt.ShiftModifier) == QtCore.Qt.ShiftModifier: + modifier = modifier * 2 + if (QModifiers & QtCore.Qt.ControlModifier) == QtCore.Qt.ControlModifier: + modifier = modifier * 10 + if (QModifiers & QtCore.Qt.AltModifier) == QtCore.Qt.AltModifier: + modifier = modifier * 100 + return modifier + + def spin_down(self): + """Reduces the value.""" + self._spin(sign=-1) + + def spin_up(self): + """Increases the value.""" + self._spin(sign=1) + + def _set_slider(self, value): + """Updates the spinbutton controls.""" + low, high = self._get_current_range() + self.control.button_lo.setEnabled(low is None or low < value) + self.control.button_hi.setEnabled(high is None or value < high) + + +class RangeTextEditor(BaseRangeEditor): + """Editor for ranges that displays a text field. + + If the user enters a value that is outside the allowed range, + the background of the field changes color to indicate an error. + """ - def update_object(self): - """Handles the user entering input data in the edit control.""" - try: - value = eval(str(self.control.text())) - if self.evaluate is not None: - value = self.evaluate(value) + def _make_control(self): - if not (self.low <= value <= self.high): - value = self.low - col = ErrorColor - else: - col = OKColor + low, high = self._get_current_range() + fvalue = self._clip(self.value, low, high) + fvalue_text = self.string_value(fvalue) - self.value = value - except Exception: - col = ErrorColor + control = QtGui.QWidget() + control.text = self._make_text_entry(fvalue_text) + return control - if self.control is not None: - pal = QtGui.QPalette(self.control.palette()) - pal.setColor(QtGui.QPalette.Base, col) - self.control.setPalette(pal) + @staticmethod + def _do_layout(control): + layout = QtGui.QHBoxLayout(control) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(control.text) # ------------------------------------------------------------------------- diff --git a/traitsui/testing/tester/_ui_tester_registry/qt4/_traitsui/range_editor.py b/traitsui/testing/tester/_ui_tester_registry/qt4/_traitsui/range_editor.py index 8c3aab604..e7e3fe560 100644 --- a/traitsui/testing/tester/_ui_tester_registry/qt4/_traitsui/range_editor.py +++ b/traitsui/testing/tester/_ui_tester_registry/qt4/_traitsui/range_editor.py @@ -13,6 +13,7 @@ LogRangeSliderEditor, RangeTextEditor, SimpleSliderEditor, + SimpleSpinEditor ) from traitsui.testing.tester.command import KeyClick @@ -100,6 +101,8 @@ def register(registry): SimpleSliderEditor, LogRangeSliderEditor, LargeRangeSliderEditor, + SimpleSpinEditor, + RangeTextEditor ] for target_class in targets: registry.register_location( @@ -109,6 +112,12 @@ def register(registry): textbox=wrapper._target.control.text ), ) + _registry_helper.register_editable_textbox_handlers( + registry=registry, + target_class=target_class, + widget_getter=lambda wrapper: wrapper._target.control.text, + ) + for target_class in targets[:3]: registry.register_location( target_class=target_class, locator_class=Slider, @@ -116,11 +125,6 @@ def register(registry): slider=wrapper._target.control.slider ), ) - _registry_helper.register_editable_textbox_handlers( - registry=registry, - target_class=RangeTextEditor, - widget_getter=lambda wrapper: wrapper._target.control, - ) LocatedTextbox.register(registry) diff --git a/traitsui/testing/tester/_ui_tester_registry/wx/_interaction_helpers.py b/traitsui/testing/tester/_ui_tester_registry/wx/_interaction_helpers.py index a0c3acd48..2b1e637e4 100644 --- a/traitsui/testing/tester/_ui_tester_registry/wx/_interaction_helpers.py +++ b/traitsui/testing/tester/_ui_tester_registry/wx/_interaction_helpers.py @@ -41,7 +41,7 @@ def _create_event(event_type, control): def mouse_click(func): """Decorator function for mouse clicks. Decorated functions will return if they are not enabled. Additionally, this handles the delay for the - click. + click as well as set the focus to the control if not already set. Parameters ---------- @@ -70,6 +70,8 @@ def mouse_click_handler(*, control, delay, **kwargs): "Nothing was performed." ) return + if not control.HasFocus(): + control.SetFocus() wx.MilliSleep(delay) func(control=control, delay=delay, **kwargs) @@ -78,7 +80,7 @@ def mouse_click_handler(*, control, delay, **kwargs): @mouse_click def mouse_click_button(control, delay): - """Performs a mouce click on a wx button. + """Performs a mouse click on a wx button. Parameters ---------- @@ -93,7 +95,7 @@ def mouse_click_button(control, delay): @mouse_click def mouse_click_checkbox(control, delay): - """Performs a mouce click on a wx check box. + """Performs a mouse click on a wx check box. Parameters ---------- @@ -102,6 +104,7 @@ def mouse_click_checkbox(control, delay): delay: int Time delay (in ms) in which click will be performed. """ + click_event = _create_event(wx.wxEVT_COMMAND_CHECKBOX_CLICKED, control) control.SetValue(not control.GetValue()) control.ProcessWindowEvent(click_event) @@ -109,7 +112,7 @@ def mouse_click_checkbox(control, delay): @mouse_click def mouse_click_combobox_or_choice(control, index, delay): - """Performs a mouce click on either a wx combo box or a wx choice on the + """Performs a mouse click on either a wx combo box or a wx choice on the entry at the given index. Parameters @@ -141,7 +144,7 @@ def mouse_click_combobox_or_choice(control, index, delay): @mouse_click def mouse_click_listbox(control, index, delay): - """Performs a mouce click on a wx list box on the entry at + """Performs a mouse click on a wx list box on the entry at the given index. Parameters @@ -176,7 +179,7 @@ def mouse_click_radiobutton(control, delay): @mouse_click def mouse_click_object(control, delay): - """Performs a mouce click on a wxTextCtrl. + """Performs a mouse click on a wxTextCtrl. Parameters ---------- @@ -185,8 +188,6 @@ def mouse_click_object(control, delay): delay: int Time delay (in ms) in which click will be performed. """ - if not control.HasFocus(): - control.SetFocus() click_event = _create_event(wx.wxEVT_COMMAND_LEFT_CLICK, control) control.ProcessWindowEvent(click_event) @@ -292,6 +293,7 @@ def key_click_text_entry( Useful for when the TextEntry.GetSelection is overridden by a subclass that does not conform to the common API. """ + if not (control.IsEnabled() and control.IsEditable()): raise Disabled("{!r} is disabled.".format(control)) if not control.HasFocus(): @@ -312,6 +314,13 @@ def key_click_text_entry( else: pos = control.GetInsertionPoint() control.Remove(max(0, pos - 1), pos) + elif interaction.key in {"Right", "Left"}: + wx.MilliSleep(delay) + n = len(control.GetValue()) + pos = control.GetInsertionPoint() + step = 1 if interaction.key == "Right" else -1 + newpos = max(0, min(pos + step, n)) + control.SetInsertionPoint(newpos) else: check_key_compat(interaction.key) wx.MilliSleep(delay) diff --git a/traitsui/testing/tester/_ui_tester_registry/wx/_traitsui/range_editor.py b/traitsui/testing/tester/_ui_tester_registry/wx/_traitsui/range_editor.py index 59e7dbe8b..2b9bb0d14 100644 --- a/traitsui/testing/tester/_ui_tester_registry/wx/_traitsui/range_editor.py +++ b/traitsui/testing/tester/_ui_tester_registry/wx/_traitsui/range_editor.py @@ -13,6 +13,7 @@ LogRangeSliderEditor, RangeTextEditor, SimpleSliderEditor, + SimpleSpinEditor ) from traitsui.testing.tester.command import KeyClick @@ -100,6 +101,8 @@ def register(registry): SimpleSliderEditor, LogRangeSliderEditor, LargeRangeSliderEditor, + SimpleSpinEditor, + RangeTextEditor ] for target_class in targets: registry.register_location( @@ -109,6 +112,12 @@ def register(registry): textbox=wrapper._target.control.text ), ) + _registry_helper.register_editable_textbox_handlers( + registry=registry, + target_class=target_class, + widget_getter=lambda wrapper: wrapper._target.control.text, + ) + for target_class in targets[:3]: registry.register_location( target_class=target_class, locator_class=Slider, @@ -116,11 +125,6 @@ def register(registry): slider=wrapper._target.control.slider ), ) - _registry_helper.register_editable_textbox_handlers( - registry=registry, - target_class=RangeTextEditor, - widget_getter=lambda wrapper: wrapper._target.control, - ) LocatedTextbox.register(registry) diff --git a/traitsui/tests/editors/test_range_editor.py b/traitsui/tests/editors/test_range_editor.py index 7b500d9e4..0c125efb1 100644 --- a/traitsui/tests/editors/test_range_editor.py +++ b/traitsui/tests/editors/test_range_editor.py @@ -8,6 +8,9 @@ # # Thanks for using Enthought open source! +# from traits.etsconfig.api import ETSConfig +# ETSConfig.toolkit = 'wx' + import platform import unittest @@ -18,7 +21,6 @@ KeyClick, KeySequence, Slider, - TargetRegistry, Textbox, UITester, ) @@ -32,32 +34,6 @@ is_windows = platform.system() == "Windows" -def _register_simple_spin(registry): - """Register interactions for the given registry for a SimpleSpinEditor. - - If there are any conflicts, an error will occur. - - This is kept separate from the below register function because the - SimpleSpinEditor is not yet implemented on wx. This function can be used - with a local reigstry for tests. - - Parameters - ---------- - registry : TargetRegistry - The registry being registered to. - """ - from traitsui.testing.tester._ui_tester_registry.qt4 import ( - _registry_helper, - ) - from traitsui.qt4.range_editor import SimpleSpinEditor - - _registry_helper.register_editable_textbox_handlers( - registry=registry, - target_class=SimpleSpinEditor, - widget_getter=lambda wrapper: wrapper._target.control.lineEdit(), - ) - - class RangeModel(HasTraits): value = Int(1) @@ -83,6 +59,7 @@ def check_range_enum_editor_format_func(self, style): high=3, format_func=lambda v: "{:02d}".format(v), mode="enum", + show_error_dialog=False ), style=style, ) @@ -150,16 +127,14 @@ def test_range_text_editor_set_with_text_valid(self): self.assertEqual(model.value, 10) self.assertEqual(displayed, str(model.value)) - # the tester support code is not yet implemented for Wx SimpleSpinEditor - @requires_toolkit([ToolkitName.qt]) + @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) def test_simple_spin_editor_set_with_text_valid(self): model = RangeModel() view = View( Item("value", editor=RangeEditor(low=1, high=12, mode="spinner")) ) - LOCAL_REGISTRY = TargetRegistry() - _register_simple_spin(LOCAL_REGISTRY) - tester = UITester(registries=[LOCAL_REGISTRY]) + + tester = UITester() with tester.create_ui(model, dict(view=view)) as ui: # sanity check self.assertEqual(model.value, 1) @@ -175,7 +150,7 @@ def test_simple_spin_editor_set_with_text_valid(self): def check_slider_set_with_text_after_empty(self, mode): model = RangeModel() view = View( - Item("value", editor=RangeEditor(low=1, high=12, mode=mode)) + Item("value", editor=RangeEditor(low=1, high=12, mode=mode, show_error_dialog=False)) ) tester = UITester() with tester.create_ui(model, dict(view=view)) as ui: @@ -199,13 +174,12 @@ def test_large_range_slider_editor_set_with_text_after_empty(self): def test_log_range_slider_editor_set_with_text_after_empty(self): return self.check_slider_set_with_text_after_empty(mode='logslider') - # on wx the text style editor gives an error whenever the textbox - # is empty, even if enter has not been pressed. - @requires_toolkit([ToolkitName.qt]) + @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) def test_range_text_editor_set_with_text_after_empty(self): model = RangeModel() view = View( - Item("value", editor=RangeEditor(low=1, high=12, mode="text")) + Item("value", editor=RangeEditor(low=1, high=12, mode="text", + show_error_dialog=False)) ) tester = UITester() with tester.create_ui(model, dict(view=view)) as ui: @@ -219,16 +193,15 @@ def test_range_text_editor_set_with_text_after_empty(self): self.assertEqual(model.value, 11) self.assertEqual(displayed, str(model.value)) - # the tester support code is not yet implemented for Wx SimpleSpinEditor - @requires_toolkit([ToolkitName.qt]) + @requires_toolkit([ToolkitName.qt, ToolkitName.wx]) def test_simple_spin_editor_set_with_text_after_empty(self): model = RangeModel() view = View( - Item("value", editor=RangeEditor(low=1, high=12, mode="spinner")) + Item("value", editor=RangeEditor(low=1, high=12, mode="spinner", + show_error_dialog=False)) ) - LOCAL_REGISTRY = TargetRegistry() - _register_simple_spin(LOCAL_REGISTRY) - tester = UITester(registries=[LOCAL_REGISTRY]) + + tester = UITester() with tester.create_ui(model, dict(view=view)) as ui: number_field_text = tester.find_by_name(ui, "value") number_field_text.perform(KeyClick("Right")) @@ -314,7 +287,8 @@ def num_to_time(num): model = RangeModel() view = View( - Item("float_value", editor=RangeEditor(format_func=num_to_time)) + Item("float_value", editor=RangeEditor(format_func=num_to_time, + show_error_dialog=False)) ) tester = UITester() with tester.create_ui(model, dict(view=view)) as ui: @@ -332,7 +306,8 @@ def test_editor_factory_format(self): model = RangeModel() with self.assertWarns(DeprecationWarning): view = View( - Item("float_value", editor=RangeEditor(format="%s ...")) + Item("float_value", editor=RangeEditor(format="%s ...", + show_error_dialog=False)) ) tester = UITester() with tester.create_ui(model, dict(view=view)) as ui: @@ -350,7 +325,8 @@ def test_editor_format(self): model = RangeModel() with self.assertWarns(DeprecationWarning): view = View( - Item("float_value", editor=RangeEditor(format="%s ...")) + Item("float_value", editor=RangeEditor(format="%s ...", + show_error_dialog=False)) ) tester = UITester() with tester.create_ui(model, dict(view=view)) as ui: @@ -367,7 +343,8 @@ def test_set_text_out_of_range(self): model = RangeModel() view = View( Item( - 'float_value', editor=RangeEditor(mode='text', low=0.0, high=1) + 'float_value', editor=RangeEditor(mode='text', low=0.0, high=1, + show_error_dialog=False) ), ) tester = UITester() diff --git a/traitsui/tests/editors/test_range_editor_text.py b/traitsui/tests/editors/test_range_editor_text.py index b20688165..d39ef0db2 100644 --- a/traitsui/tests/editors/test_range_editor_text.py +++ b/traitsui/tests/editors/test_range_editor_text.py @@ -13,16 +13,21 @@ A RangeEditor in mode 'text' for an Int allows values out of range. """ + +# from traits.etsconfig.api import ETSConfig +# ETSConfig.toolkit = 'wx' + import unittest from traits.has_traits import HasTraits from traits.trait_types import Float, Int from traitsui.item import Item from traitsui.view import View +from traitsui import editor from traitsui.editors.range_editor import RangeEditor from traitsui.testing.tester import command -from traitsui.testing.tester.ui_tester import UITester +from traitsui.testing.api import UITester, DisplayedText, Textbox from traitsui.tests._tools import ( BaseTestMixin, requires_toolkit, @@ -37,8 +42,11 @@ class NumberWithRangeEditor(HasTraits): traits_view = View( Item(label="Range should be 3 to 8. Enter 1, then press OK"), - Item("number", editor=RangeEditor(low=3, high=8, mode="text")), - buttons=["OK"], + Item("number", editor=RangeEditor(low=3, high=8, mode="text", + show_error_dialog=False, + enter_set=True, + auto_set=False)), + buttons=["OK", "Cancel"], ) @@ -46,40 +54,115 @@ class FloatWithRangeEditor(HasTraits): """Dialog containing a RangeEditor in 'spinner' mode for an Int.""" number = Float(5.0) + number2 = int(3) traits_view = View( - Item("number", editor=RangeEditor(low=0.0, high=12.0)), buttons=["OK"] + Item("number", editor=RangeEditor(low=-1.0, high=None, mode='spinner', + enter_set=True, + auto_set=False)), + Item("number2", editor=RangeEditor(low=-1, high=1100000, mode='text', + enter_set=True, + auto_set=False)), + Item('number2', style='readonly'), + Item('number', style='readonly'), + buttons=["OK", "Cancel"] ) class TestRangeEditorText(BaseTestMixin, unittest.TestCase): def setUp(self): BaseTestMixin.setUp(self) + editor.UNITTESTING = True # if True raise error on all errors. Makes it easier to test def tearDown(self): BaseTestMixin.tearDown(self) + editor.UNITTESTING = False # Reset so it does not affect the other test. @requires_toolkit([ToolkitName.wx]) - def test_wx_text_editing(self): - # behavior: when editing the text part of a spin control box, pressing + def test_text_editing(self): + # behavior: when editing the text part of a range control box, pressing # the OK button should update the value of the HasTraits class # (tests a bug where this fails with an AttributeError) num = NumberWithRangeEditor() tester = UITester() + with tester.create_ui(num) as ui: + # the following is equivalent to setting the text in the text + # control, then pressing OK + number_field = tester.find_by_name(ui, "number") + text = number_field.locate(Textbox()) + # text = number_field + + displayed = text.inspect(DisplayedText()) + assert displayed == '3' + assert num.number == 3 + text.perform(command.KeyClick("Backspace")) # make sure to delete 3 + text.perform(command.KeyClick("6")) + + displayed = text.inspect(DisplayedText()) + assert displayed == '6' + assert num.number == 3 # num.number is not updated yet + text.perform(command.KeyClick("Enter")) # Update num.number + displayed = text.inspect(DisplayedText()) + assert displayed == '6' + assert num.number == 6 + text.perform(command.KeyClick("Backspace")) # make sure to delete 6 + text.perform(command.KeyClick("7")) + + displayed = text.inspect(DisplayedText()) + assert displayed == '7' + assert num.number == 6 + + ok_button = tester.find_by_id(ui, "OK") + ok_button.perform(command.MouseClick()) + + # the number traits should be 7 + assert num.number == 7 + + @requires_toolkit([ToolkitName.wx]) + def test_text_editing_out_of_bounds(self): + # behavior: + # When the value entered is out of bounds an error should be raised + # when editing the text part of a range control box, pressing + # the Cancel button should update the value of the HasTraits class + # since the focus is changed. + # (tests a bug where this fails with an AttributeError) + + num = NumberWithRangeEditor() + assert num.number == 0 + tester = UITester() with tester.create_ui(num) as ui: # the following is equivalent to setting the text in the text # control, then pressing OK text = tester.find_by_name(ui, "number") + displayed = text.inspect(DisplayedText()) + assert displayed == '3' + assert num.number == 3 + text.perform(command.KeyClick("Backspace")) # make sure to delete 3 text.perform(command.KeyClick("1")) - text.perform(command.KeyClick("Enter")) + displayed = text.inspect(DisplayedText()) + assert displayed == '1' + with self.assertRaises(RuntimeError) as context: + text.perform(command.KeyClick("Enter")) + self.assertTrue("The value (1) must be larger than 3!" in str(context.exception)) + # the number traits should be 3 + assert num.number == 3 + text.perform(command.KeyClick("Backspace")) + text.perform(command.KeyClick("4")) + displayed = text.inspect(DisplayedText()) + assert displayed == '4' + assert num.number == 3 + + cancel_button = tester.find_by_id(ui, "Cancel") + cancel_button.perform(command.MouseClick()) - # the number traits should be between 3 and 8 - self.assertTrue(3 <= num.number <= 8) + assert num.number == 4 if __name__ == "__main__": + editor.UNITTESTING = False # if False show errordialog on all errors. Makes it easier to test manually # Executing the file opens the dialog for manual testing - num = NumberWithRangeEditor() + # num = NumberWithRangeEditor() + num = FloatWithRangeEditor() num.configure_traits() print(num.number) diff --git a/traitsui/tests/test_editor.py b/traitsui/tests/test_editor.py index 1e8cf4551..2321fad13 100644 --- a/traitsui/tests/test_editor.py +++ b/traitsui/tests/test_editor.py @@ -22,6 +22,7 @@ Range, Undefined, ) +from traitsui.api import View, RangeEditor, Item from traits.trait_base import xgetattr from traitsui.context_value import ContextValue, CVFloat, CVInt @@ -948,7 +949,9 @@ def test_editor_error_msg(self): from pyface.qt import QtCore, QtGui class Foo(HasTraits): - x = Range(low=0.0, high=1.0, value=0.5, exclude_low=True) + x = Range(low=0.0, high=1.0, value=0.5, exclude_low=True, + enter_set=True, auto_set=False) + # TODO: Range must be modified to have a show_error_dialog attribute too! foo = Foo() tester = UITester(auto_process_events=False) diff --git a/traitsui/wx/editor.py b/traitsui/wx/editor.py index 53ee5e1a5..34c2c2484 100644 --- a/traitsui/wx/editor.py +++ b/traitsui/wx/editor.py @@ -63,14 +63,16 @@ def update_editor(self): def error(self, excp): """Handles an error that occurs while setting the object's trait value.""" - dlg = wx.MessageDialog( - self.control, - str(excp), - self.description + " value error", - wx.OK | wx.ICON_INFORMATION, - ) - dlg.ShowModal() - dlg.Destroy() + super().error(excp) + if self.factory.show_error_dialog: + dlg = wx.MessageDialog( + self.control, + str(excp), + self.description + " value error", + wx.OK | wx.ICON_INFORMATION, + ) + dlg.ShowModal() + dlg.Destroy() def set_tooltip_text(self, control, text): """Sets the tooltip for a specified control.""" diff --git a/traitsui/wx/range_editor.py b/traitsui/wx/range_editor.py index 0b610ba2c..5bb1fe982 100644 --- a/traitsui/wx/range_editor.py +++ b/traitsui/wx/range_editor.py @@ -12,26 +12,24 @@ """ -import sys -import wx - +import ast from math import log10 -from traits.api import TraitError, Str, Float, Any, Bool +import wx -from .editor_factory import TextEditor +from traits.api import TraitError, Str, Float, Any, Bool from .editor import Editor -from .constants import OKColor, ErrorColor +from .constants import OKColor from .helper import TraitsUIPanel, Slider - +# pylint: disable=no-member if not hasattr(wx, "wx.wxEVT_SCROLL_ENDSCROLL"): wxEVT_SCROLL_ENDSCROLL = wx.wxEVT_SCROLL_CHANGED else: - wxEVT_SCROLL_ENDSCROLL = wx.wxEVT_SCROLL_ENDSCROLL + wxEVT_SCROLL_ENDSCROLL = wx.wxEVT_SCROLL_ENDSCROLL # @UndefinedVariable # ------------------------------------------------------------------------- @@ -56,22 +54,10 @@ def _set_value(self, value): value = self.evaluate(value) Editor._set_value(self, value) - -class SimpleSliderEditor(BaseRangeEditor): - """Simple style of range editor that displays a slider and a text field. - - The user can set a value either by moving the slider or by typing a value - in the text field. - """ - - # ------------------------------------------------------------------------- - # Trait definitions: - # ------------------------------------------------------------------------- - - #: Low value for the slider range + #: Low value for the range low = Any() - #: High value for the slider range + #: High value for the range high = Any() #: Deprecated: This trait is no longer used. See enthought/traitsui#1704 @@ -84,6 +70,11 @@ def init(self, parent): """Finishes initializing the editor by creating the underlying toolkit widget. """ + self._init_with_factory_defaults() + self.control = self._make_control(parent) + self._do_layout(self.control) + + def _init_with_factory_defaults(self): factory = self.factory if not factory.low_name: self.low = factory.low @@ -94,150 +85,91 @@ def init(self, parent): self.evaluate = factory.evaluate self.sync_value(factory.evaluate_name, "evaluate", "from") - size = wx.DefaultSize - if factory.label_width > 0: - size = wx.Size(factory.label_width, 20) - self.sync_value(factory.low_name, "low", "from") self.sync_value(factory.high_name, "high", "from") - self.control = panel = TraitsUIPanel(parent, -1) - sizer = wx.BoxSizer(wx.HORIZONTAL) - fvalue = self.value - - if not (self.low <= fvalue <= self.high): - fvalue_text = "" - fvalue = self.low - else: - try: - fvalue_text = self.string_value(fvalue) - except (ValueError, TypeError) as e: - fvalue_text = "" - ivalue = self._convert_to_slider(fvalue) + def _make_control(self, parent): + raise NotImplementedError - self._label_lo = wx.StaticText( - panel, - -1, - "999999", - size=size, - style=wx.ALIGN_RIGHT | wx.ST_NO_AUTORESIZE, - ) - sizer.Add(self._label_lo, 0, wx.ALIGN_CENTER) - panel.slider = slider = Slider( - panel, - -1, - ivalue, - 0, - 10000, - size=wx.Size(80, 20), - style=wx.SL_HORIZONTAL | wx.SL_AUTOTICKS, - ) - slider.SetTickFreq(1000) - slider.SetValue(1) - slider.SetPageSize(1000) - slider.SetLineSize(100) - slider.Bind(wx.EVT_SCROLL, self.update_object_on_scroll) - sizer.Add(slider, 1, wx.EXPAND) - self._label_hi = wx.StaticText(panel, -1, "999999", size=size) - sizer.Add(self._label_hi, 0, wx.ALIGN_CENTER) - - panel.text = text = wx.TextCtrl( - panel, - -1, - fvalue_text, - size=wx.Size(56, 20), - style=wx.TE_PROCESS_ENTER, - ) - panel.Bind( - wx.EVT_TEXT_ENTER, self.update_object_on_enter, id=text.GetId() - ) - text.Bind(wx.EVT_KILL_FOCUS, self.update_object_on_enter) + def _do_layout(self, control): + raise NotImplementedError - sizer.Add(text, 0, wx.LEFT | wx.EXPAND, 4) + def _clip(self, fvalue, low, high): + """Returns fvalue clipped between low and high""" - low_label = factory.low_label - if factory.low_name != "": - low_label = self.string_value(self.low) + try: + if low is not None and fvalue < low: + return low + if high is not None and high < fvalue: + return high + except: + return low + return fvalue - high_label = factory.high_label - if factory.high_name != "": - high_label = self.string_value(self.high) + def _make_text_entry(self, control, fvalue_text, size=wx.Size(56, 20)): + if self.factory.enter_set: + text = wx.TextCtrl(control, + -1, + fvalue_text, + size=size, + style=wx.TE_PROCESS_ENTER) + control.Bind(wx.EVT_TEXT_ENTER, self.update_object_on_enter, id=text.GetId()) + else: + text = wx.TextCtrl(control, -1, fvalue_text, size=size) - self._label_lo.SetLabel(low_label) - self._label_hi.SetLabel(high_label) - self.set_tooltip(slider) - self.set_tooltip(self._label_lo) - self.set_tooltip(self._label_hi) + text.Bind(wx.EVT_KILL_FOCUS, self.update_object_on_enter) + if self.factory.auto_set: + control.Bind(wx.EVT_TEXT, self.update_object_on_enter, id=text.GetId()) self.set_tooltip(text) - # Set-up the layout: - panel.SetSizerAndFit(sizer) + return text - def update_object_on_scroll(self, event): - """Handles the user changing the current slider value.""" - value = self._convert_from_slider(event.GetPosition()) - event_type = event.GetEventType() - if ( - (event_type == wxEVT_SCROLL_ENDSCROLL) - or ( - self.factory.auto_set - and (event_type == wx.wxEVT_SCROLL_THUMBTRACK) - ) - or ( - self.factory.enter_set - and (event_type == wx.wxEVT_SCROLL_THUMBRELEASE) - ) - ): - try: - self.ui_changing = True - self.control.text.SetValue(self.string_value(value)) - self.value = value - except TraitError: - pass - finally: - self.ui_changing = False + def _validate(self, value): + if self.low is not None and value < self.low: + message = "The value ({}) must be larger than {}!" + raise ValueError(message.format(value, self.low)) + if self.high is not None and value > self.high: + message = "The value ({}) must be smaller than {}!" + raise ValueError(message.format(value, self.high)) + if not self.factory.is_float and isinstance(value, float): + message = "The value must be an integer, but a value of {} was specified." + raise ValueError(message.format(value)) + + def _set_color(self, color): + if self.control is not None: + self.control.text.SetBackgroundColour(color) + self.control.text.Refresh() def update_object_on_enter(self, event): """Handles the user pressing the Enter key in the text field.""" if isinstance(event, wx.FocusEvent): event.Skip() - # There are cases where this method is called with self.control == - # None. + # It is possible the event is processed after the control is removed + # from the editor if self.control is None: return try: - try: - value = self.control.text.GetValue().strip() - if self.factory.is_float: - value = float(value) - else: - value = int(value) - except Exception as ex: - # The user entered something that didn't eval as a number (e.g., 'foo'). - # Pretend it didn't happen (i.e. do not change self.value). - value = self.value - self.control.text.SetValue(str(value)) - # for compound editor, value may be non-numeric - if not isinstance(value, (int, float)): - return - + value = ast.literal_eval(self.control.text.GetValue()) + self._validate(value) self.value = value - if not self.ui_changing: - self.control.slider.SetValue( - self._convert_to_slider(self.value) - ) - self.control.text.SetBackgroundColour(OKColor) - self.control.text.Refresh() - if self._error is not None: - self._error = None - self.ui.errors -= 1 - except TraitError: - pass + + except Exception as excp: + self.error(excp) + return + + if not self.ui_changing: + self._set_slider(value) + + self._set_color(OKColor) + if self._error is not None: + self._error = None + self.ui.errors -= 1 def error(self, excp): - """Handles an error that occurs while setting the object's trait value.""" + """ Handles an error that occurs while setting the object's trait value. + """ if self._error is None: self._error = True self.ui.errors += 1 @@ -248,36 +180,21 @@ def update_editor(self): """Updates the editor when the object trait changes externally to the editor. """ - value = self.value - try: - text = self.string_value(value) - 1 // (self.low <= value <= self.high) - except: - text = "" - value = self.low + fvalue = self._clip(self.value, self.low, self.high) + text = self.string_value(fvalue) - ivalue = self._convert_to_slider(value) + self.ui_changing = True self.control.text.SetValue(text) - self.control.slider.SetValue(ivalue) + self.ui_changing = False + self._set_slider(fvalue) - def _convert_to_slider(self, value): - """Returns the slider setting corresponding to the user-supplied value.""" - if self.high > self.low: - ivalue = int( - (float(value - self.low) / (self.high - self.low)) * 10000.0 - ) - else: - ivalue = self.low - return ivalue + def _set_slider(self, value): + """Updates the slider range controls.""" + # Do nothing for non-sliders. - def _convert_from_slider(self, ivalue): - """Returns the float or integer value corresponding to the slider - setting. - """ - value = self.low + ((float(ivalue) / 10000.0) * (self.high - self.low)) - if not self.factory.is_float: - value = int(round(value)) - return value + def _get_current_range(self): + low, high = self.low, self.high + return low, high def get_error_control(self): """Returns the editor's control for indicating error status.""" @@ -285,27 +202,151 @@ def get_error_control(self): def _low_changed(self, low): if self.value < low: - if self.factory.is_float: - self.value = float(low) - else: - self.value = int(low) - - if self._label_lo is not None: - self._label_lo.SetLabel(self.string_value(low)) + self.value = float(low) if self.factory.is_float else int(low) + if self.control is not None: self.update_editor() def _high_changed(self, high): if self.value > high: - if self.factory.is_float: - self.value = float(high) - else: - self.value = int(high) - - if self._label_hi is not None: - self._label_hi.SetLabel(self.string_value(high)) + self.value = float(high) if self.factory.is_float else int(high) + if self.control is not None: self.update_editor() +class SimpleSliderEditor(BaseRangeEditor): + """ Simple style of range editor that displays a slider and a text field. + + The user can set a value either by moving the slider or by typing a value + in the text field. + """ + + # ------------------------------------------------------------------------- + # Trait definitions: See BaseRangeEditor + # ------------------------------------------------------------------------- + + def _make_control(self, parent): + + low, high = self._get_current_range() + fvalue = self._clip(self.value, low, high) + fvalue_text = self.string_value(fvalue) + + size = self._get_default_size() + + control = TraitsUIPanel(parent, -1) + control.label_lo = self._make_label_low(control, low, size) + control.slider = self._make_slider(control, fvalue) + control.label_hi = self._make_label_high(control, high, size) + control.text = self._make_text_entry(control, fvalue_text) + return control + + @staticmethod + def _do_layout(control): + sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(control.label_lo, 0, wx.ALIGN_CENTER) + sizer.Add(control.slider, 1, wx.EXPAND) + sizer.Add(control.label_hi, 0, wx.ALIGN_CENTER) + sizer.Add(control.text, 0, wx.LEFT | wx.EXPAND, 4) + control.SetSizerAndFit(sizer) + + def _get_default_size(self): + if self.factory.label_width > 0: + return wx.Size(self.factory.label_width, 20) + return wx.DefaultSize + + def _get_label_high(self, high): + if self.factory.high_name != "": + return self.string_value(high) + return self.factory.high_label + + def _get_label_low(self, low): + if self.factory.low_name != "": + return self.string_value(low) + return self.factory.low_label + + def _make_label_low(self, control, low, size): + low_label = self._get_label_low(low) + style = wx.ALIGN_RIGHT | wx.ST_NO_AUTORESIZE + label_lo = wx.StaticText(control, -1, low_label, size=size, style=style) + self.set_tooltip(label_lo) + + return label_lo + + def _make_slider(self, control, fvalue): + ivalue = self._convert_to_slider(fvalue) + slider = Slider(control, + -1, + value=ivalue, + minValue=0, + maxValue=10000, + size=wx.Size(80, 20), + style=wx.SL_HORIZONTAL | wx.SL_AUTOTICKS) + slider.SetTickFreq(1000) + slider.SetValue(1) + slider.SetPageSize(1000) + slider.SetLineSize(100) + slider.Bind(wx.EVT_SCROLL, self.update_object_on_scroll) + self.set_tooltip(slider) + + return slider + + def _make_label_high(self, control, high, size): + high_label = self._get_label_high(high) + label_hi = wx.StaticText(control, -1, high_label, size=size) + self.set_tooltip(label_hi) + return label_hi + + def update_object_on_scroll(self, event): + """Handles the user changing the current slider value.""" + event_type = event.GetEventType() + if ( + (event_type == wxEVT_SCROLL_ENDSCROLL) + or ( + self.factory.auto_set + and (event_type == wx.wxEVT_SCROLL_THUMBTRACK) + ) + or ( + self.factory.enter_set + and (event_type == wx.wxEVT_SCROLL_THUMBRELEASE) + ) + ): + value = self._convert_from_slider(event.GetPosition()) + try: + self.ui_changing = True + self.control.text.SetValue(self.string_value(value)) + self.value = value + except TraitError: + pass + finally: + self.ui_changing = False + + def _set_slider(self, value): + """Updates the slider range controls.""" + low, high = self._get_current_range() + self.control.label_lo.SetLabel(self.string_value(low)) + self.control.label_hi.SetLabel(self.string_value(high)) + ivalue = self._convert_to_slider(value) + self.control.slider.SetValue(ivalue) + + def _convert_to_slider(self, value): + """Returns the slider setting corresponding to the user-supplied value.""" + low, high = self._get_current_range() + if high > low: + return int(float(value - low) / (high - low) * 10000.0) + if low is None: + return 0 + return low + + def _convert_from_slider(self, ivalue): + """Returns the float or integer value corresponding to the slider + setting. + """ + low, high = self._get_current_range() + value = low + ((float(ivalue) / 10000.0) * (high - low)) + if not self.factory.is_float: + value = int(round(value)) + return value + + # ------------------------------------------------------------------------- class LogRangeSliderEditor(SimpleSliderEditor): # ------------------------------------------------------------------------- @@ -313,28 +354,25 @@ class LogRangeSliderEditor(SimpleSliderEditor): def _convert_to_slider(self, value): """Returns the slider setting corresponding to the user-supplied value.""" - value = max(value, self.low) - ivalue = int( - (log10(value) - log10(self.low)) - / (log10(self.high) - log10(self.low)) - * 10000.0 - ) - return ivalue + low, high = self._get_current_range() + value = max(value, low) + return int((log10(value) - log10(low)) / (log10(high) - log10(low)) * 10000.0) def _convert_from_slider(self, ivalue): """Returns the float or integer value corresponding to the slider setting. """ - value = float(ivalue) / 10000.0 * (log10(self.high) - log10(self.low)) + low, high = self._get_current_range() + value = float(ivalue) / 10000.0 * (log10(high) - log10(low)) # Do this to handle floating point errors, where fvalue may exceed # self.high. - fvalue = min(self.low * 10 ** (value), self.high) + fvalue = min(low * 10 ** (value), high) if not self.factory.is_float: fvalue = int(round(fvalue)) return fvalue -class LargeRangeSliderEditor(BaseRangeEditor): +class LargeRangeSliderEditor(SimpleSliderEditor): """A slider editor for large ranges. The editor displays a slider and a text field. A subset of the total @@ -343,552 +381,340 @@ class LargeRangeSliderEditor(BaseRangeEditor): """ # ------------------------------------------------------------------------- - # Trait definitions: + # Trait definitions: See BaseRangeEditor # ------------------------------------------------------------------------- - #: Low value for the slider range - low = Any(0) - - #: High value for the slider range - high = Any(1) - - #: Low end of displayed range + #: Low end of displayed slider range cur_low = Float() - #: High end of displayed range + #: High end of displayed slider range cur_high = Float() - #: Flag indicating that the UI is in the process of being updated - ui_changing = Bool(False) - def init(self, parent): """Finishes initializing the editor by creating the underlying toolkit widget. """ - factory = self.factory - - # Initialize using the factory range defaults: - self.low = factory.low - self.high = factory.high - self.evaluate = factory.evaluate + self._init_with_factory_defaults() + self.init_current_range(self.value) + self.control = self._make_control(parent) + # Set-up the layout: + self._do_layout(self.control) - # Hook up the traits to listen to the object. - self.sync_value(factory.low_name, "low", "from") - self.sync_value(factory.high_name, "high", "from") - self.sync_value(factory.evaluate_name, "evaluate", "from") + def _make_control(self, parent): + control = super()._make_control(parent) + low, high = self._get_current_range() - self.init_range() - low = self.cur_low - high = self.cur_high + control.button_lo = self._make_button_low(control, low) + control.button_hi = self._make_button_high(control, high) + return control - self._set_format() - self.control = panel = TraitsUIPanel(parent, -1) + @staticmethod + def _do_layout(control): sizer = wx.BoxSizer(wx.HORIZONTAL) - fvalue = self.value - try: - fvalue_text = self._format % fvalue - 1 // (low <= fvalue <= high) - except: - fvalue_text = "" - fvalue = low - - if high > low: - ivalue = int((float(fvalue - low) / (high - low)) * 10000) - else: - ivalue = low - - # Lower limit label: - label_lo = wx.StaticText(panel, -1, "999999") - panel.label_lo = label_lo - sizer.Add(label_lo, 2, wx.ALIGN_CENTER) - - # Lower limit button: + sizer.Add(control.label_lo, 2, wx.ALIGN_CENTER) + sizer.Add(control.button_lo, 1, wx.ALIGN_CENTER) + sizer.Add(control.slider, 6, wx.EXPAND) + sizer.Add(control.button_hi, 1, wx.ALIGN_CENTER) + sizer.Add(control.label_hi, 2, wx.ALIGN_CENTER) + sizer.Add(control.text, 0, wx.LEFT | wx.EXPAND, 4) + control.SetSizerAndFit(sizer) + + def _make_button_low(self, control, low): bmp = wx.ArtProvider.GetBitmap(wx.ART_GO_BACK, size=(15, 15)) - button_lo = wx.BitmapButton( - panel, - -1, - bitmap=bmp, - size=(-1, 20), - style=wx.BU_EXACTFIT | wx.NO_BORDER, - ) - panel.button_lo = button_lo + button_lo = wx.BitmapButton(control, + -1, + bitmap=bmp, + size=(-1, 20), + style=wx.BU_EXACTFIT | wx.NO_BORDER, + name="button_lo") button_lo.Bind(wx.EVT_BUTTON, self.reduce_range) - sizer.Add(button_lo, 1, wx.ALIGN_CENTER) - - # Slider: - panel.slider = slider = Slider( - panel, - -1, - ivalue, - 0, - 10000, - size=wx.Size(80, 20), - style=wx.SL_HORIZONTAL | wx.SL_AUTOTICKS, - ) - slider.SetTickFreq(1000) - slider.SetValue(1) - slider.SetPageSize(1000) - slider.SetLineSize(100) - slider.Bind(wx.EVT_SCROLL, self.update_object_on_scroll) - sizer.Add(slider, 6, wx.EXPAND) + button_lo.Enable(self.low is None or low != self.low) + return button_lo + + def _make_button_high(self, control, high): - # Upper limit button: bmp = wx.ArtProvider.GetBitmap(wx.ART_GO_FORWARD, size=(15, 15)) - button_hi = wx.BitmapButton( - panel, - -1, - bitmap=bmp, - size=(-1, 20), - style=wx.BU_EXACTFIT | wx.NO_BORDER, - ) - panel.button_hi = button_hi + button_hi = wx.BitmapButton(control, + -1, + bitmap=bmp, + size=(-1, 20), + style=wx.BU_EXACTFIT | wx.NO_BORDER, + name="button_hi") button_hi.Bind(wx.EVT_BUTTON, self.increase_range) - sizer.Add(button_hi, 1, wx.ALIGN_CENTER) - - # Upper limit label: - label_hi = wx.StaticText(panel, -1, "999999") - panel.label_hi = label_hi - sizer.Add(label_hi, 2, wx.ALIGN_CENTER) - - # Text entry: - panel.text = text = wx.TextCtrl( - panel, - -1, - fvalue_text, - size=wx.Size(56, 20), - style=wx.TE_PROCESS_ENTER, - ) - panel.Bind( - wx.EVT_TEXT_ENTER, self.update_object_on_enter, id=text.GetId() - ) - text.Bind(wx.EVT_KILL_FOCUS, self.update_object_on_enter) - - sizer.Add(text, 0, wx.LEFT | wx.EXPAND, 4) - - # Set-up the layout: - panel.SetSizerAndFit(sizer) - label_lo.SetLabel(str(low)) - label_hi.SetLabel(str(high)) - self.set_tooltip(slider) - self.set_tooltip(label_lo) - self.set_tooltip(label_hi) - self.set_tooltip(text) - - # Update the ranges and button just in case. - self.update_range_ui() - - def update_object_on_scroll(self, event): - """Handles the user changing the current slider value.""" - low = self.cur_low - high = self.cur_high - value = low + ((float(event.GetPosition()) / 10000.0) * (high - low)) - self.control.text.SetValue(self._format % value) - event_type = event.GetEventType() - try: - self.ui_changing = True - if ( - (event_type == wxEVT_SCROLL_ENDSCROLL) - or ( - self.factory.auto_set - and (event_type == wx.wxEVT_SCROLL_THUMBTRACK) - ) - or ( - self.factory.enter_set - and (event_type == wx.wxEVT_SCROLL_THUMBRELEASE) - ) - ): - if self.factory.is_float: - self.value = value - else: - self.value = int(value) - finally: - self.ui_changing = False - - def update_object_on_enter(self, event): - """Handles the user pressing the Enter key in the text field.""" - if isinstance(event, wx.FocusEvent): - event.Skip() - # It is possible the event is processed after the control is removed - # from the editor - if self.control is None: - return - try: - value = self.control.text.GetValue().strip() - try: - if self.factory.is_float: - value = float(value) - else: - value = int(value) - except Exception as ex: - # The user entered something that didn't eval as a number (e.g., 'foo'). - # Pretend it didn't happen (i.e. do not change self.value). - value = self.value - self.control.text.SetValue(str(value)) - - self.value = value - self.control.text.SetBackgroundColour(OKColor) - self.control.text.Refresh() - # Update the slider range. - # Set ui_changing to True to avoid recursion: - # the update_range_ui method will try to set the value in the text - # box, which will again fire this method if auto_set is True. - if not self.ui_changing: - self.ui_changing = True - self.init_range() - self.update_range_ui() - self.ui_changing = False - if self._error is not None: - self._error = None - self.ui.errors -= 1 - except TraitError as excp: - pass - - def error(self, excp): - """Handles an error that occurs while setting the object's trait value.""" - if self._error is None: - self._error = True - self.ui.errors += 1 - super().error(excp) - self.set_error_state(True) - - def update_editor(self): - """Updates the editor when the object trait changes externally to the - editor. - """ - value = self.value - low = self.low - high = self.high - try: - text = self._format % value - 1 / (low <= value <= high) - except: - value = low - self.value = value + button_hi.Enable(self.high is None or high != self.high) + return button_hi - if not self.ui_changing: - self.init_range() - self.update_range_ui() - - def update_range_ui(self): + def _set_slider(self, value): """Updates the slider range controls.""" - low, high = self.cur_low, self.cur_high - value = self.value - self._set_format() - self.control.label_lo.SetLabel(self._format % low) - self.control.label_hi.SetLabel(self._format % high) - if high > low: - ivalue = int((float(value - low) / (high - low)) * 10000.0) - else: - ivalue = low - self.control.slider.SetValue(ivalue) - text = self._format % self.value - self.control.text.SetValue(text) - factory = self.factory - f_low, f_high = self.low, self.high + low, high = self._get_current_range() + if not low <= value <= high: + low, high = self.init_current_range(value) - if low == f_low: - self.control.button_lo.Disable() - else: - self.control.button_lo.Enable() + ivalue = self._convert_to_slider(value) + self.control.slider.SetValue(ivalue) - if high == f_high: - self.control.button_hi.Disable() - else: - self.control.button_hi.Enable() + fmt = self._get_format() + self.control.label_lo.SetLabel(fmt % low) + self.control.label_hi.SetLabel(fmt % high) + self.control.button_lo.Enable(self.low is None or low != self.low) + self.control.button_hi.Enable(self.high is None or high != self.high) - def init_range(self): - """Initializes the slider range controls.""" - value = self.value - factory = self.factory + def init_current_range(self, value): + """Initializes the current slider range controls, cur_low and cur_high.""" low, high = self.low, self.high - if (high is None) and (low is not None): - high = -low - mag = abs(value) - if mag <= 10.0: - cur_low = max(value - 10, low) - cur_high = min(value + 10, high) - else: - d = 0.5 * (10 ** int(log10(mag) + 1)) - cur_low = max(low, value - d) - cur_high = min(high, value + d) + mag = max(abs(value), 1) + rounded_value = 10 ** int(log10(mag)) + fact_hi, fact_lo = (10, 1) if value >= 0 else (-1, -10) + cur_low = rounded_value * fact_lo + cur_high = rounded_value * fact_hi + if mag <= 10: + if value >= 0: + cur_low *= -1 + else: + cur_high *= -1 + + if low is not None and cur_low < low: + cur_low = low + if high is not None and high < cur_high: + cur_high = high self.cur_low, self.cur_high = cur_low, cur_high + return self.cur_low, self.cur_high def reduce_range(self, event): """Reduces the extent of the displayed range.""" - factory = self.factory - low, high = self.low, self.high + value = self.value + low = self.low + + old_cur_low = self.cur_low if abs(self.cur_low) < 10: - self.cur_low = max(-10, low) - self.cur_high = min(10, high) - elif self.cur_low > 0: - self.cur_high = self.cur_low - self.cur_low = max(low, self.cur_low / 10) + value = value - 10 + self.cur_low = max(-10, low) if low is not None else -10 + if old_cur_low - self.cur_low > 9: + self.cur_high = old_cur_low else: - self.cur_high = self.cur_low - self.cur_low = max(low, self.cur_low * 10) + fact = 0.1 if self.cur_low > 0 else 10 + value = value * fact + new_cur_low = self.cur_low * fact + self.cur_low = max(low, new_cur_low) if low is not None else new_cur_low + if self.cur_low == new_cur_low: + self.cur_high = old_cur_low - self.ui_changing = True - self.value = min(max(self.value, self.cur_low), self.cur_high) - self.ui_changing = False - self.update_range_ui() + value = min(max(value, self.cur_low), self.cur_high) + + if self.factory.is_float is False: + value = int(value) + self.value = value + self.update_editor() def increase_range(self, event): """Increased the extent of the displayed range.""" - factory = self.factory - low, high = self.low, self.high + value = self.value + high = self.high + old_cur_high = self.cur_high if abs(self.cur_high) < 10: - self.cur_low = max(-10, low) - self.cur_high = min(10, high) - elif self.cur_high > 0: - self.cur_low = self.cur_high - self.cur_high = min(high, self.cur_high * 10) + value = value + 10 + self.cur_high = min(10, high) if high is not None else 10 + if self.cur_high - old_cur_high > 9: + self.cur_low = old_cur_high else: - self.cur_low = self.cur_high - self.cur_high = min(high, self.cur_high / 10) + fact = 10 if self.cur_high > 0 else 0.1 + value = value * fact + new_cur_high = self.cur_high * fact + self.cur_high = min(high, new_cur_high) if high is not None else new_cur_high + if self.cur_high == new_cur_high: + self.cur_low = old_cur_high - self.ui_changing = True - self.value = min(max(self.value, self.cur_low), self.cur_high) - self.ui_changing = False - self.update_range_ui() + value = min(max(value, self.cur_low), self.cur_high) - def _set_format(self): - self._format = "%d" - factory = self.factory - low, high = self.cur_low, self.cur_high - diff = high - low - if factory.is_float: - if diff > 99999: - self._format = "%.2g" - elif diff > 1: - self._format = "%%.%df" % max(0, 4 - int(log10(high - low))) - else: - self._format = "%.3f" - - def get_error_control(self): - """Returns the editor's control for indicating error status.""" - return self.control.text - - def _low_changed(self, low): - if self.control is not None: - if self.value < low: - if self.factory.is_float: - self.value = float(low) - else: - self.value = int(low) + if self.factory.is_float is False: + value = int(value) - self.update_editor() + self.value = value + self.update_editor() - def _high_changed(self, high): - if self.control is not None: - if self.value > high: - if self.factory.is_float: - self.value = float(high) - else: - self.value = int(high) + def _get_format(self): + if self.factory.is_float: + low, high = self._get_current_range() + diff = high - low + if diff > 99999: + return "%.2g" + elif diff > 1: + return "%%.%df" % max(0, 4 - int(log10(diff))) + return "%.3f" + return "%d" - self.update_editor() + def _get_current_range(self): + return self.cur_low, self.cur_high class SimpleSpinEditor(BaseRangeEditor): - """A simple style of range editor that displays a spin box control.""" - - # ------------------------------------------------------------------------- - # Trait definitions: - # ------------------------------------------------------------------------- - - #: Low value for the slider range - low = Any() - - #: High value for the slider range - high = Any() - - def init(self, parent): - """Finishes initializing the editor by creating the underlying toolkit - widget. - """ - factory = self.factory - if not factory.low_name: - self.low = factory.low - - if not factory.high_name: - self.high = factory.high - - self.sync_value(factory.low_name, "low", "from") - self.sync_value(factory.high_name, "high", "from") - low = self.low - high = self.high - self.control = wx.SpinCtrl( - parent, -1, self.str_value, min=low, max=high, initial=self.value - ) - parent.Bind( - wx.EVT_SPINCTRL, self.update_object, id=self.control.GetId() - ) - self.set_tooltip() - - def update_object(self, event): - """Handles the user selecting a new value in the spin box.""" - if self.control is None: - return - self._locked = True - try: - self.value = self.control.GetValue() - finally: - self._locked = False - - def update_editor(self): - """Updates the editor when the object trait changes externally to the - editor. - """ - if not self._locked: - try: - self.control.SetValue(int(self.value)) - except: - pass - - def _low_changed(self, low): - if self.value < low: - if self.factory.is_float: - self.value = float(low) - else: - self.value = int(low) - if self.control: - self.control.SetRange(self.low, self.high) - self.control.SetValue(int(self.value)) - - def _high_changed(self, high): - if self.value > high: - if self.factory.is_float: - self.value = float(high) - else: - self.value = int(high) - if self.control: - self.control.SetRange(self.low, self.high) - self.control.SetValue(int(self.value)) - - -class RangeTextEditor(TextEditor): - """Editor for ranges that displays a text field. If the user enters a - value that is outside the allowed range, the background of the field - changes color to indicate an error. + """A simple style of range editor that displays a spin box control. + + The SimpleSpinEditor catches 3 different types of events that will increase/decrease + the value of the class: + 1) Spin event generated by pushing the up/down spinbutton; + 2) Key pressed event generated by pressing the arrow- or page-up/down of the keyboard. + 3) Mouse wheel event generated by rolling the mouse wheel up or down. + + In addition, there are some other functionalities: + + - ``Shift`` + arrow = 2 * increment (or ``Shift`` + mouse wheel); + - ``Ctrl`` + arrow = 10 * increment (or ``Ctrl`` + mouse wheel); + - ``Alt`` + arrow = 100 * increment (or ``Alt`` + mouse wheel); + - Combinations of ``Shift``, ``Ctrl``, ``Alt`` increment the + step value by the product of the factors; + - ``PgUp`` & ``PgDn`` = 10 * increment * the product of the ``Shift``, ``Ctrl``, ``Alt`` + factors; """ # ------------------------------------------------------------------------- - # Trait definitions: + # Trait definitions: See BaseRangeEditor # ------------------------------------------------------------------------- - #: Low value for the slider range - low = Any() + #: Step value for the spinner + step = Any(1) - #: High value for the slider range - high = Any() + def _make_control(self, parent): - #: Function to evaluate floats/ints - evaluate = Any() - - def init(self, parent): - """Finishes initializing the editor by creating the underlying toolkit - widget. - """ - if not self.factory.low_name: - self.low = self.factory.low + low, high = self._get_current_range() + fvalue = self._clip(self.value, low, high) + fvalue_text = self.string_value(fvalue) - if not self.factory.high_name: - self.high = self.factory.high + control = TraitsUIPanel(parent, -1) + control.text = self._make_text_entry(control, fvalue_text) + control.button_lo = self._make_button_low(control, fvalue) + control.button_hi = self._make_button_high(control, fvalue) + return control - self.sync_value(self.factory.low_name, "low", "from") - self.sync_value(self.factory.high_name, "high", "from") + def _make_text_entry(self, control, fvalue_text, size=wx.Size(41, 20)): + text = super()._make_text_entry(control, fvalue_text, size) + text.Bind(wx.EVT_MOUSEWHEEL, self.on_mouse_wheel) + text.Bind(wx.EVT_CHAR, self.on_char) + return text - if self.factory.enter_set: - control = wx.TextCtrl( - parent, -1, self.str_value, style=wx.TE_PROCESS_ENTER - ) - parent.Bind( - wx.EVT_TEXT_ENTER, self.update_object, id=control.GetId() - ) + @staticmethod + def _do_layout(control): + sizer = wx.BoxSizer(wx.HORIZONTAL) + sizer.Add(control.text, 12, wx.LEFT | wx.EXPAND) + spinctrl_sizer = wx.BoxSizer(wx.VERTICAL) + spinctrl_sizer.Add(control.button_hi, 1, wx.ALIGN_CENTER) + spinctrl_sizer.Add(control.button_lo, 1, wx.ALIGN_CENTER) + sizer.Add(spinctrl_sizer, 1, wx.RIGHT) + control.SetSizerAndFit(sizer) + + def _make_button_low(self, control, value): + bmp = wx.ArtProvider.GetBitmap(wx.ART_GO_DOWN, size=(15, 10)) + button_lo = wx.BitmapButton(control, + -1, + bitmap=bmp, + size=(15, 12), + style=wx.BU_EXACTFIT | wx.NO_BORDER, + name="button_lo") + # button_lo.Bind(wx.EVT_BUTTON, self.spin_down) + button_lo.Bind(wx.EVT_LEFT_DOWN, self.spin_down) + button_lo.Bind(wx.EVT_MOUSEWHEEL, self.on_mouse_wheel) + button_lo.Enable(self.low is None or self.low < value) + return button_lo + + def _make_button_high(self, control, value): + + bmp = wx.ArtProvider.GetBitmap(wx.ART_GO_UP, size=(15, 10)) + button_hi = wx.BitmapButton(control, + -1, + bitmap=bmp, + size=(15, 12), + style=wx.BU_EXACTFIT | wx.NO_BORDER, + name="button_hi") + # button_hi.Bind(wx.EVT_BUTTON, self.spin_up) + button_hi.Bind(wx.EVT_LEFT_DOWN, self.spin_up) + button_hi.Bind(wx.EVT_MOUSEWHEEL, self.on_mouse_wheel) + + button_hi.Enable(self.high is None or value < self.high) + return button_hi + + def _spin(self, step): + + low, high = self._get_current_range() + value = self._clip(self.value + step, low, high) + + if self.factory.is_float is False: + value = int(value) + self.value = value + self.update_editor() + + def _get_modifier(self, event): + + modifier = self.step + if event.ShiftDown(): + modifier = modifier * 2 + if event.ControlDown(): + modifier = modifier * 10 + if event.AltDown(): + modifier = modifier * 100 + return modifier + + def on_char(self, event): + keycode = event.GetKeyCode() + if keycode == wx.WXK_UP: + self.spin_up(event) + elif keycode == wx.WXK_DOWN: + self.spin_down(event) + elif keycode == wx.WXK_PAGEUP: + self.spin_up(event, 10) + elif keycode == wx.WXK_PAGEDOWN: + self.spin_down(event, 10) else: - control = wx.TextCtrl(parent, -1, self.str_value) + event.Skip() - control.Bind(wx.EVT_KILL_FOCUS, self.update_object) + def spin_down(self, event, fact=1): + """Reduces the value.""" + step = -1 * self._get_modifier(event) * fact + self._spin(step) - if self.factory.auto_set: - parent.Bind(wx.EVT_TEXT, self.update_object, id=control.GetId()) + def spin_up(self, event, fact=1): + """Increases the value.""" + step = self._get_modifier(event) * fact + self._spin(step) - self.evaluate = self.factory.evaluate - self.sync_value(self.factory.evaluate_name, "evaluate", "from") + def on_mouse_wheel(self, event): + sign = -1 if event.WheelRotation < 0 else 1 + step = sign * self._get_modifier(event) + self._spin(step) - self.control = control - self.set_tooltip() + def _set_slider(self, value): + """Updates the spinbutton controls.""" + low, high = self._get_current_range() + self.control.button_lo.Enable(low is None or low < value) + self.control.button_hi.Enable(high is None or value < high) - def update_object(self, event): - """Handles the user entering input data in the edit control.""" - if isinstance(event, wx.FocusEvent): - event.Skip() - # It is possible the event is processed after the control is removed - # from the editor - if self.control is None: - return +class RangeTextEditor(BaseRangeEditor): + """Editor for ranges that displays a text field. - value = self.control.GetValue() + If the user enters a value that is outside the allowed range, + the background of the field changes color to indicate an error. + """ - # Try to convert the string value entered by the user to a numerical - # value. - try: - if self.evaluate is not None: - value = self.evaluate(value) - else: - if self.factory.is_float: - value = float(value) - else: - value = int(value) - except Exception as excp: - # The conversion failed. - self.error(excp) - return + def _make_control(self, parent): - if value < self.low or value > self.high: - self.set_error_state(True) - return + low, high = self._get_current_range() + fvalue = self._clip(self.value, low, high) + fvalue_text = self.string_value(fvalue) - # Try to assign the numerical value to the trait. - # This may fail because of constraints on the trait. - try: - self.value = value - self.control.SetBackgroundColour(OKColor) - self.control.Refresh() - if self._error is not None: - self._error = None - self.ui.errors -= 1 - except TraitError as excp: - pass + control = TraitsUIPanel(parent, -1) + control.text = self._make_text_entry(control, fvalue_text) + return control - def error(self, excp): - """Handles an error that occurs while setting the object's trait value.""" - if self._error is None: - self._error = True - self.ui.errors += 1 - super().error(excp) - self.set_error_state(True) + @staticmethod + def _do_layout(control): + pass - def _low_changed(self, low): - if self.value < low: - if self.factory.is_float: - self.value = float(low) - else: - self.value = int(low) - if self.control: - self.control.SetValue(int(self.value)) - def _high_changed(self, high): - if self.value > high: - if self.factory.is_float: - self.value = float(high) - else: - self.value = int(high) - if self.control: - self.control.SetValue(int(self.value)) +# ------------------------------------------------------------------------- +# 'SimpleEnumEditor' factory adaptor: +# ------------------------------------------------------------------------- def SimpleEnumEditor(parent, factory, ui, object, name, description, **kwargs): @@ -906,7 +732,7 @@ def CustomEnumEditor( if factory._enum is None: import traitsui.editors.enum_editor as enum_editor - factory._enum = enum_editor.EnumEditor( + factory._enum = enum_editor.ToolkitEditorFactory( values=list(range(factory.low, factory.high + 1)), cols=factory.cols, )