diff --git a/traitsui/editors/tuple_editor.py b/traitsui/editors/tuple_editor.py index 5e51bdb11..e8562685b 100644 --- a/traitsui/editors/tuple_editor.py +++ b/traitsui/editors/tuple_editor.py @@ -16,40 +16,25 @@ """ Defines the tuple editor factory for all traits user interface toolkits. """ -#------------------------------------------------------------------------- -# Imports: -#------------------------------------------------------------------------- - from __future__ import absolute_import from traits.trait_base import SequenceTypes - -from traits.api import Bool, HasTraits, List, Tuple, Unicode, Int, Any, TraitType +from traits.api import ( + Bool, Callable, HasTraits, List, BaseTuple, Unicode, Int, Any, TraitType) # CIRCULAR IMPORT FIXME: Importing from the source rather than traits.ui.api # to avoid circular imports, as this EditorFactory will be part of # traits.ui.api as well. from ..view import View - from ..group import Group - from ..item import Item - from ..editor_factory import EditorFactory - from ..editor import Editor -#------------------------------------------------------------------------- -# 'ToolkitEditorFactory' class: -#------------------------------------------------------------------------- - class ToolkitEditorFactory(EditorFactory): """ Editor factory for tuple editors. """ - #------------------------------------------------------------------------- - # Trait definitions: - #------------------------------------------------------------------------- # Trait definitions for each tuple field types = Any @@ -73,9 +58,10 @@ class ToolkitEditorFactory(EditorFactory): # 'enter_set' metadata or an editor defined. enter_set = Bool(False) -#------------------------------------------------------------------------- -# 'SimpleEditor' class: -#------------------------------------------------------------------------- + # The validation function to use for the Tuple. If the edited trait offers + # already a validation function then the value of this trait will be + # ignored. + fvalidate = Callable class SimpleEditor(Editor): @@ -84,53 +70,37 @@ class SimpleEditor(Editor): The editor displays an editor for each of the fields in the tuple, based on the type of each field. """ - #------------------------------------------------------------------------- - # Finishes initializing the editor by creating the underlying toolkit - # widget: - #------------------------------------------------------------------------- def init(self, parent): """ Finishes initializing the editor by creating the underlying toolkit widget. """ self._ts = ts = TupleStructure(self) - self._ui = ui = ts.view.ui(ts, parent, kind='subpanel').set( - parent=self.ui) + self._ui = ui = ts.view.ui( + ts, parent, kind='subpanel').set(parent=self.ui) self.control = ui.control self.set_tooltip() - #------------------------------------------------------------------------- - # Updates the editor when the object trait changes external to the editor: - #------------------------------------------------------------------------- - def update_editor(self): """ Updates the editor when the object trait changes external to the editor. """ ts = self._ts - for i, value in enumerate(self.value): - setattr(ts, 'f%d' % i, value) - #------------------------------------------------------------------------- - # Returns the editor's control for indicating error status: - #------------------------------------------------------------------------- + for i, value in enumerate(self.value): + setattr(ts, 'f{0}'.format(i), value) + if ts.fvalidate is not None: + setattr(ts, 'invalid{0}'.format(i), False) def get_error_control(self): """ Returns the editor's control for indicating error status. """ return self._ui.get_error_controls() -#------------------------------------------------------------------------- -# 'TupleStructure' class: -#------------------------------------------------------------------------- - class TupleStructure(HasTraits): """ Creates a view containing items for each field in a tuple. """ - #------------------------------------------------------------------------- - # Trait definitions: - #------------------------------------------------------------------------- # Editor this structure is linked to editor = Any @@ -141,9 +111,8 @@ class TupleStructure(HasTraits): # Number of tuple fields fields = Int - #------------------------------------------------------------------------- - # Initializes the object: - #------------------------------------------------------------------------- + # The validation function to use for the Tuple. + fvalidate = Callable def __init__(self, editor): """ Initializes the object. @@ -157,7 +126,7 @@ def __init__(self, editor): # Save the reference to the editor: self.editor = editor - # Get the tuple we are mirroring: + # Get the tuple we are mirroring. object = editor.value # For each tuple field, add a trait with the appropriate trait @@ -167,9 +136,16 @@ def __init__(self, editor): len_labels = len(labels) len_editors = len(editors) + # Get global validation function. + type = editor.value_trait.handler + fvalidate = getattr(type, 'fvalidate', None) + if fvalidate is None: + fvalidate = factory.fvalidate + self.fvalidate = fvalidate + + # Get field types. if types is None: - type = editor.value_trait.handler - if isinstance(type, Tuple): + if isinstance(type, BaseTuple): types = type.types if not isinstance(types, SequenceTypes): @@ -200,11 +176,17 @@ def __init__(self, editor): if i < len_editors: field_editor = editors[i] - name = 'f%d' % i - self.add_trait(name, type(value, event='field', - auto_set=auto_set, - enter_set=enter_set)) - item = Item(name=name, label=label, editor=field_editor) + name = 'f{0}'.format(i) + self.add_trait(name, type( + value, event='field', auto_set=auto_set, enter_set=enter_set)) + if fvalidate is not None: + invalid = 'invalid{0}'.format(i) + self.add_trait(invalid, Bool) + else: + invalid = '' + + item = Item( + name, label=label, editor=field_editor, invalid=invalid) if cols <= 1: content.append(item) else: @@ -216,21 +198,27 @@ def __init__(self, editor): self.view = View(Group(show_labels=(len_labels != 0), *content)) - #------------------------------------------------------------------------- - # Updates the underlying tuple when any field changes value: - #------------------------------------------------------------------------- - def _field_changed(self, name, old, new): """ Updates the underlying tuple when any field changes value. """ + editor = self.editor + value = editor.value index = int(name[1:]) value = self.editor.value if new != value[index]: - self.editor.value = tuple( - [getattr(self, 'f%d' % i) for i in range(self.fields)]) + new_value = tuple( + getattr(self, 'f{0}'.format(i)) for i in range(self.fields)) + if self.fvalidate is not None: + if self.fvalidate(new_value): + editor.value = new_value + for i in range(self.fields): + setattr(self, 'invalid{0}'.format(i), False) + else: + for i in range(self.fields): + setattr(self, 'invalid{0}'.format(i), True) + else: + editor.value = new_value # Define the TupleEditor class. TupleEditor = ToolkitEditorFactory - -### EOF ####################################################################### diff --git a/traitsui/qt4/text_editor.py b/traitsui/qt4/text_editor.py index 4e592f6b8..b2b7482bd 100644 --- a/traitsui/qt4/text_editor.py +++ b/traitsui/qt4/text_editor.py @@ -164,7 +164,6 @@ def _get_user_value(self): value = self.control.text() except AttributeError: value = self.control.toPlainText() - value = unicode(value) try: diff --git a/traitsui/tests/_tools.py b/traitsui/tests/_tools.py index 94a183d67..73ed14022 100644 --- a/traitsui/tests/_tools.py +++ b/traitsui/tests/_tools.py @@ -14,6 +14,7 @@ #------------------------------------------------------------------------------ import sys +import threading import traceback import inspect from functools import partial, wraps @@ -24,6 +25,7 @@ from traits.etsconfig.api import ETSConfig import traits.trait_notifiers + # ######### Testing tools @@ -115,6 +117,7 @@ def skip_if_null(test_func): Some tests handle both wx and Qt in one go, but many things are not defined in the null backend. Use this decorator to skip the test. """ + @wraps(test_func) def wrapper(*args, **kwargs): if _is_current_backend('null'): @@ -183,6 +186,90 @@ def get_dialog_size(ui_control): return ui_control.size().width(), ui_control.size().height() +def set_value(editor, value): + """ Set the value on the control managed by the editor. + + """ + if is_current_backend_wx(): + editor.control.SetValue(value) + + elif is_current_backend_qt4(): + editor.control.setText(value) + editor.update_object() + + +@contextmanager +def dispose_ui_after(function, timeout, *args, **kwargs): + """ A context manager that will create a ui and dispose it on exit. + + """ + ui = function(*args, **kwargs) + + from pyface.gui import GUI + + timeout_event = threading.Event() + + def on_timeout(timeout_event): + timeout_event.set() + dispose_ui(ui) + + gui = GUI() + gui.invoke_after(timeout * 1000, on_timeout, timeout_event) + + try: + yield ui + finally: + if timeout_event.is_set(): + message = 'UI was forcibly destroyed after {0} sec' + raise AssertionError(message.format(timeout)) + else: + dispose_ui(ui) + + +def dispose_ui(ui): + """ Dispose the ui, by killing the application object. + + """ + from pyface.gui import GUI + if ui is not None or ui.control is None: + ui.dispose() + gui = GUI() + if is_current_backend_qt4(): + from pyface.qt import QtGui + app = QtGui.QApplication.instance() + gui.invoke_later(app.closeAllWindows) + gui.invoke_after(2, app.quit) + app.exec_() + elif is_current_backend_wx(): + import wx + for w in wx.GetTopLevelWindows(): + wx.CallAfter(w.Close) + app = wx.GetApp() + gui.invoke_later(app.Exit) + app.MainLoop() + + +def get_traitsui_editor(ui, path): + """ Get an editor from a UI using a '/' separated list of trait names. + + '/' is used to access the editor of a trait in a sub-element of the + view. + """ + + names = path.split('/') + + while True: + name = names.pop(0) + editor = ui.get_editors(name)[0] + + if len(names) > 0: + ui = editor._ui + else: + break + + return editor + + # ######### Debug tools def apply_on_children(func, node, _level=0): @@ -229,8 +316,6 @@ def wx_announce_when_destroyed(node): def destroy_wrapped(): print 'Destroying:', node - #print 'Stack is' - #traceback.print_stack() _destroy_method() print 'Destroyed:', node diff --git a/traitsui/tests/editors/test_tuple_editor.py b/traitsui/tests/editors/test_tuple_editor.py index 0135100be..1f5508d32 100644 --- a/traitsui/tests/editors/test_tuple_editor.py +++ b/traitsui/tests/editors/test_tuple_editor.py @@ -1,67 +1,267 @@ -# -*- coding: utf-8 -*- -""" -Created on Fri Sep 20 13:17:20 2013 +#------------------------------------------------------------------------------ +# +# Copyright (c) 2014, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +#------------------------------------------------------------------------------ +from __future__ import ( + division, print_function, unicode_literals, absolute_import) -@author: yves -""" +import unittest -from __future__ import division, print_function, unicode_literals, absolute_import - -from contextlib import contextmanager - -from traits.has_traits import HasTraits -from traits.trait_types import Int, Tuple +from traits.api import Float, Int, HasStrictTraits, Str, Tuple, ValidatedTuple +from traits.testing.api import UnittestTools +from traitsui.tests._tools import ( + dispose_ui_after, get_traitsui_editor, set_value, skip_if_null) from traitsui.item import Item from traitsui.view import View -from traitsui.tests._tools import * +class DummyModel(HasStrictTraits): + + value_range = ValidatedTuple( + Int(0), Int(1), fvalidate=lambda x: x[0] < x[1]) + + data = Tuple(Float, Float, Str) + + +class TestTupleEditor(UnittestTools, unittest.TestCase): + + @skip_if_null + def setUp(self): + from traitsui.api import TupleEditor + self.tuple_editor = TupleEditor + + def tearDown(self): + self.tuple_editor = None + + def test_value_update(self): + # Regression test for #179 + dummy_model = DummyModel() + with dispose_ui_after(dummy_model.edit_traits, 5): + with self.assertTraitChanges(dummy_model, 'data', count=1): + dummy_model.data = (3, 4.6, 'nono') + + def test_ui_creation(self): + dummy_model = DummyModel() + with dispose_ui_after(dummy_model.edit_traits, 5) as ui: + editor = get_traitsui_editor(ui, 'data') + self.assertIsInstance(editor.factory, self.tuple_editor) + self.assertEqual(editor.value, (0.0, 0.0, '')) + self.assertEqual( + editor._ts.trait_get( + ['f0', 'f1', 'f2', 'invalid0', 'invalid1', 'invalid2']), + {'f0': 0.0, 'f1': 0.0, 'f2': ''}) + + editor = get_traitsui_editor(ui, 'value_range') + self.assertIsInstance(editor.factory, self.tuple_editor) + self.assertEqual(editor.value, (0, 1)) + self.assertEqual( + editor._ts.trait_get(['f0', 'f1', 'invalid0', 'invalid1']), + {'f0': 0, 'f1': 1, 'invalid0': False, 'invalid1': False}) + + def test_ui_invalid_due_to_custom_validation(self): + dummy_model = DummyModel() + with dispose_ui_after(dummy_model.edit_traits, 5) as ui: + editor = get_traitsui_editor(ui, 'value_range') + fields_ui = editor._ui + f0_editor = get_traitsui_editor(fields_ui, 'f0') + f1_editor = get_traitsui_editor(fields_ui, 'f1') + + set_value(f0_editor, '5') # 5 < 1 -> invalid + self.assertTrue(f0_editor.in_error_state()) + self.assertTrue(f1_editor.in_error_state()) + self.assertEqual(editor.value, (0, 1)) + + set_value(f0_editor, '-3') # -3 < 1 -> valid + self.assertIsNone(f0_editor.in_error_state()) + self.assertIsNone(f1_editor.in_error_state()) + self.assertEqual(editor.value, (-3, 1)) + + set_value(f1_editor, '-4') # -3 < -4 -> invalid + self.assertTrue(f0_editor.in_error_state()) + self.assertTrue(f1_editor.in_error_state()) + self.assertEqual(editor.value, (-3, 1)) + + set_value(f1_editor, '0') # -3 < 0 -> valid + self.assertIsNone(f0_editor.in_error_state()) + self.assertIsNone(f1_editor.in_error_state()) + self.assertEqual(editor.value, (-3, 0)) + + def test_ui_invalid_due_to_field_validation(self): + dummy_model = DummyModel() + with dispose_ui_after(dummy_model.edit_traits, 5) as ui: + editor = get_traitsui_editor(ui, 'data') + fields_ui = editor._ui + f0_editor = get_traitsui_editor(fields_ui, 'f0') + f1_editor = get_traitsui_editor(fields_ui, 'f1') + f2_editor = get_traitsui_editor(fields_ui, 'f2') + + set_value(f1_editor, 'nono') # str -> invalid + self.assertFalse(f0_editor.in_error_state()) + self.assertTrue(f1_editor.in_error_state()) + self.assertFalse(f2_editor.in_error_state()) + self.assertEqual(editor.value, (0.0, 0.0, '')) + + editor = get_traitsui_editor(ui, 'value_range') + fields_ui = editor._ui + f0_editor = get_traitsui_editor(fields_ui, 'f0') + f1_editor = get_traitsui_editor(fields_ui, 'f1') + + set_value(f1_editor, '0.2') # float -> invalid + self.assertTrue(f1_editor.in_error_state()) + self.assertFalse(f0_editor.in_error_state()) + self.assertEqual(editor.value, (0, 1)) + + def test_when_editor_is_used_with_vertical_layout(self): + + class VSimple(HasStrictTraits): + + value_range = ValidatedTuple( + Int(0), Int(1), cols=1, fvalidate=lambda x: x[0] < x[1]) + + dummy_model = VSimple() + with dispose_ui_after(dummy_model.edit_traits, 5) as ui: + editor = get_traitsui_editor(ui, 'value_range') + self.assertIsInstance(editor.factory, self.tuple_editor) + self.assertEqual(editor.value, (0, 1)) + self.assertEqual( + editor._ts.trait_get(['f0', 'f1', 'invalid0', 'invalid1']), + {'f0': 0, 'f1': 1, 'invalid0': False, 'invalid1': False}) + + def test_when_editor_is_used_with_horizontal_layout(self): + + class HSimple(HasStrictTraits): + + value_range = ValidatedTuple( + Int(0), Int(1), cols=2, fvalidate=lambda x: x[0] < x[1]) + + dummy_model = HSimple() + with dispose_ui_after(dummy_model.edit_traits, 5) as ui: + editor = get_traitsui_editor(ui, 'value_range') + self.assertIsInstance(editor.factory, self.tuple_editor) + self.assertEqual(editor.value, (0, 1)) + self.assertEqual( + editor._ts.trait_get(['f0', 'f1', 'invalid0', 'invalid1']), + {'f0': 0, 'f1': 1, 'invalid0': False, 'invalid1': False}) + + def test_when_editor_is_used_in_the_view(self): + + class SimpleWithView(HasStrictTraits): + + value_range = ValidatedTuple( + Int(0), Int(1), fvalidate=lambda x: x[0] < x[1]) + + view = View(Item('value_range', editor=self.tuple_editor())) + + dummy_model = SimpleWithView() + with dispose_ui_after(dummy_model.edit_traits, 5) as ui: + editor = get_traitsui_editor(ui, 'value_range') + self.assertIsInstance(editor.factory, self.tuple_editor) + self.assertEqual(editor.value, (0, 1)) + self.assertEqual( + editor._ts.trait_get(['f0', 'f1', 'invalid0', 'invalid1']), + {'f0': 0, 'f1': 1, 'invalid0': False, 'invalid1': False}) + + fields_ui = editor._ui + f0_editor = get_traitsui_editor(fields_ui, 'f0') + f1_editor = get_traitsui_editor(fields_ui, 'f1') + + set_value(f1_editor, '-4') # 0 < -4 -> invalid + self.assertTrue(f0_editor.invalid) + self.assertTrue(f1_editor.invalid) + self.assertEqual(editor.value, (0, 1)) + + def test_when_validation_method_is_provided_in_the_view(self): + from traitsui.api import Item, View + + class SimpleWithView(HasStrictTraits): + + value_range = Tuple(Int(0), Int(1)) + + view = View( + Item( + 'value_range', + editor=self.tuple_editor( + fvalidate=lambda x: x[0] < x[1]))) + + dummy_model = SimpleWithView() + with dispose_ui_after(dummy_model.edit_traits, 5) as ui: + editor = get_traitsui_editor(ui, 'value_range') + self.assertIsInstance(editor.factory, self.tuple_editor) + self.assertEqual(editor.value, (0, 1)) + self.assertEqual( + editor._ts.trait_get(['f0', 'f1', 'invalid0', 'invalid1']), + {'f0': 0, 'f1': 1, 'invalid0': False, 'invalid1': False}) + + fields_ui = editor._ui + f0_editor = get_traitsui_editor(fields_ui, 'f0') + f1_editor = get_traitsui_editor(fields_ui, 'f1') + + set_value(f1_editor, '-4') # 0 < -4 -> invalid + self.assertTrue(f0_editor.invalid) + self.assertTrue(f1_editor.invalid) + self.assertEqual(editor.value, (0, 1)) -class TupleEditor(HasTraits): - """Dialog containing a Tuple of two Int's. - """ + def test_when_validation_in_trait_overrides_view(self): + from traitsui.api import Item, View - tup = Tuple(Int, Int) + class SimpleWithView(HasStrictTraits): - traits_view = View( - Item(label="Enter 4 and 6, then press OK"), - Item('tup'), - buttons=['OK'] - ) + value_range = ValidatedTuple( + Int(0), Int(1), fvalidate=lambda x: x[0] < x[1]) + view = View( + Item( + 'value_range', + editor=self.tuple_editor( + fvalidate=lambda x: x[0] == x[1]))) -@skip_if_not_qt4 -def test_qt_tuple_editor(): - # Behavior: when editing the text of a tuple editor, - # value get updated immediately. + dummy_model = SimpleWithView() + with dispose_ui_after(dummy_model.edit_traits, 5) as ui: + editor = get_traitsui_editor(ui, 'value_range') + self.assertIsInstance(editor.factory, self.tuple_editor) + self.assertEqual(editor.value, (0, 1)) + self.assertEqual( + editor._ts.trait_get(['f0', 'f1', 'invalid0', 'invalid1']), + {'f0': 0, 'f1': 1, 'invalid0': False, 'invalid1': False}) - from pyface import qt + fields_ui = editor._ui + f0_editor = get_traitsui_editor(fields_ui, 'f0') + f1_editor = get_traitsui_editor(fields_ui, 'f1') - with store_exceptions_on_all_threads(): - val = TupleEditor() - ui = val.edit_traits() + set_value(f1_editor, '-4') # 0 < -4 -> invalid + self.assertTrue(f0_editor.invalid) + self.assertTrue(f1_editor.invalid) + self.assertEqual(editor.value, (0, 1)) - # the following is equivalent to clicking in the text control of the - # range editor, enter a number, and clicking ok without defocusing + def test_invalid_state_reset_on_model_change(self): + dummy_model = DummyModel() + with dispose_ui_after(dummy_model.edit_traits, 5) as ui: + editor = get_traitsui_editor(ui, 'value_range') + fields_ui = editor._ui + f0_editor = get_traitsui_editor(fields_ui, 'f0') + f1_editor = get_traitsui_editor(fields_ui, 'f1') - # text element inside the spin control - lineedits = ui.control.findChildren(qt.QtGui.QLineEdit) - lineedits[0].setFocus() - lineedits[0].clear() - lineedits[0].insert('4') - lineedits[1].setFocus() - lineedits[1].clear() - lineedits[1].insert('6') + # given + set_value(f1_editor, '-4') # 0 < -4 -> invalid + self.assertTrue(f0_editor.in_error_state()) + self.assertTrue(f1_editor.in_error_state()) + self.assertEqual(editor.value, (0, 1)) - # if all went well, the tuple trait has been updated and its value is 4 - assert val.tup == (4, 6) + # when + dummy_model.value_range = (2, 7) - # press the OK button and close the dialog - press_ok_button(ui) + # then + self.assertEqual( + editor._ts.trait_get(['f0', 'f1', 'invalid0', 'invalid1']), + {'f0': 2, 'f1': 7, 'invalid0': False, 'invalid1': False}) if __name__ == '__main__': - # Executing the file opens the dialog for manual testing - val = TupleEditor() - val.configure_traits() - print(val.tup) + unittest.main() diff --git a/traitsui/tests/test_tuple_editor.py b/traitsui/tests/test_tuple_editor.py deleted file mode 100644 index d77dabb3f..000000000 --- a/traitsui/tests/test_tuple_editor.py +++ /dev/null @@ -1,52 +0,0 @@ -#------------------------------------------------------------------------------ -# -# Copyright (c) 2014, Enthought, Inc. -# All rights reserved. -# -# This software is provided without warranty under the terms of the BSD -# license included in enthought/LICENSE.txt and may be redistributed only -# under the conditions described in the aforementioned license. The license -# is also available online at http://www.enthought.com/licenses/BSD.txt -# -# Author: Ioannis Tziakos -# Date: Aug 2014 -# -#------------------------------------------------------------------------------ -""" -Test cases for the TupleEditor object. -""" - -import unittest - -from traits.api import Float, HasStrictTraits, Str, Tuple -from traits.testing.api import UnittestTools - -from traitsui.api import Item, TupleEditor, View -from traitsui.tests._tools import skip_if_null - - -class DummyModel(HasStrictTraits): - """ Dummy model with a Tuple trait. - """ - - data = Tuple(Float, Float, Str) - - traits_view = View(Item(name='data', editor=TupleEditor())) - - -class TestTupleEditor(UnittestTools, unittest.TestCase): - - @skip_if_null - def test_value_update(self): - # Regression test for #179 - model = DummyModel() - ui = model.edit_traits() - try: - with self.assertTraitChanges(model, 'data', count=1): - model.data = (3, 4.6, 'nono') - finally: - ui.dispose() - - -if __name__ == '__main__': - unittest.run()