diff --git a/traitsui/group.py b/traitsui/group.py index 250e9fd59..5a1541328 100644 --- a/traitsui/group.py +++ b/traitsui/group.py @@ -19,6 +19,7 @@ Delegate, Float, Instance, + Int, List, Property, Range, @@ -195,6 +196,9 @@ class Group(ViewSubElement): #: Requested height of the group (calculated from heights of contents) height = Property(Float, observe="content") + #: The number of sub-groups in the contents. + groups = Property(Int, observe="content.items") + def __init__(self, *values, **traits): """ Initializes the group object. """ @@ -271,34 +275,86 @@ def replace_include(self, view_elements): view_elements.content[id] = item item.replace_include(view_elements) + def get_content(self, allow_groups=True): + """ Returns the contents of the Group within a specified context for + building a user interface. + + This method assumes that there are no Include or False defined_when + clauses in the contetns of the group: if that is the case the group + should have been replaced by a ShadowGroup with the appropriate + content substituted, and the ShadowGroup's method will be called + instead. + """ + # Make a copy of the content: + result = self.content[:] + + # If result includes any Groups and they are not allowed, + # replace them: + if self.groups > 0: + if not allow_groups: + i = 0 + while i < len(result): + value = result[i] + if isinstance(value, Group): + items = value.get_content(False) + result[i : i + 1] = items + i += len(items) + else: + i += 1 + elif (self.groups != len(result)) and (self.layout == "normal"): + # if we have a mix of groups and items, create groups around runs of + # items + items = [] + content = [] + for item in result: + if isinstance(item, Group): + self._flush_items(content, items) + content.append(item) + else: + items.append(item) + self._flush_items(content, items) + result = content + + # Return the resulting list of objects: + return result + def get_shadow(self, ui): """ Returns a ShadowGroup object for the current Group object, which recursively resolves all embedded Include objects and which replaces each embedded Group object with a corresponding ShadowGroup. """ content = [] - groups = 0 level = ui.push_level() + shadow_needed = False for value in self.content: # Recursively replace Include objects: while isinstance(value, Include): value = ui.find(value) + shadow_needed = True # Convert Group objects to ShadowGroup objects, but include Item # objects as is (ignore any 'None' values caused by a failed # Include): if isinstance(value, Group): if self._defined_when(ui, value): - content.append(value.get_shadow(ui)) - groups += 1 + shadow_value = value.get_shadow(ui) + content.append(shadow_value) + shadow_needed |= isinstance(shadow_value, ShadowGroup) + else: + shadow_needed = shadow_needed or (value.defined_when != "") elif isinstance(value, Item): if self._defined_when(ui, value): content.append(value) + else: + shadow_needed = shadow_needed or (value.defined_when != "") ui.pop_level(level) - # Return the ShadowGroup: - return ShadowGroup(shadow=self, content=content, groups=groups) + if shadow_needed: + # Return the ShadowGroup: + return ShadowGroup(shadow=self, content=content) + else: + return self def set_container(self): """ Sets the correct container for the content. @@ -306,6 +362,14 @@ def set_container(self): for item in self.content: item.container = self + def get_id(self): + """ Returns an ID for the group. + """ + if self.id != "": + return self.id + + return ":".join([item.get_id() for item in self.get_content()]) + def _defined_when(self, ui, value): """ Should the object be defined in the user interface? """ @@ -345,6 +409,24 @@ def _parsed_label(self): """ self.show_border = True + def _flush_items(self, content, items): + """ Creates a shadow sub-group for any items contained in a specified list. + """ + if len(items) > 0: + content.append( + # Set shadow before hand to prevent delegation errors + ShadowGroup(shadow=self).trait_set( + label="", + show_border=False, + content=items, + show_labels=self.show_labels, + show_left=self.show_left, + springy=self.springy, + orientation=self.orientation, + ) + ) + del items[:] + def __repr__(self): """ Returns a "pretty print" version of the Group. """ @@ -413,6 +495,10 @@ def _get_height(self): return height + @cached_property + def _get_groups(self): + return len([item for item in self.content if isinstance(item, Group)]) + class HGroup(Group): """ A group whose items are laid out horizontally. @@ -557,9 +643,6 @@ def __init__(self, shadow, **traits): #: Group object this is a "shadow" for shadow = ReadOnly() - #: Number of ShadowGroups in **content** - groups = ReadOnly() - #: Name of the group id = ShadowDelegate @@ -630,66 +713,18 @@ def __init__(self, shadow, **traits): #: Style sheet for the panel style_sheet = ShadowDelegate - def get_content(self, allow_groups=True): - """ Returns the contents of the Group within a specified context for - building a user interface. - - This method makes sure that all Group types are of the same type (i.e., - Group or Item) and that all Include objects have been replaced by their - substituted values. - """ - # Make a copy of the content: - result = self.content[:] - - # If result includes any ShadowGroups and they are not allowed, - # replace them: - if self.groups != 0: - if not allow_groups: - i = 0 - while i < len(result): - value = result[i] - if isinstance(value, ShadowGroup): - items = value.get_content(False) - result[i : i + 1] = items - i += len(items) - else: - i += 1 - elif (self.groups != len(result)) and (self.layout == "normal"): - items = [] - content = [] - for item in result: - if isinstance(item, ShadowGroup): - self._flush_items(content, items) - content.append(item) - else: - items.append(item) - self._flush_items(content, items) - result = content - - # Return the resulting list of objects: - return result - - def get_id(self): - """ Returns an ID for the group. - """ - if self.id != "": - return self.id - - return ":".join([item.get_id() for item in self.get_content()]) - def set_container(self): """ Sets the correct container for the content. """ pass def _flush_items(self, content, items): - """ Creates a sub-group for any items contained in a specified list. + """ Creates a shadow sub-group for any items contained in a specified list. """ if len(items) > 0: content.append( # Set shadow before hand to prevent delegation errors ShadowGroup(shadow=self.shadow).trait_set( - groups=0, label="", show_border=False, content=items, diff --git a/traitsui/tests/test_group.py b/traitsui/tests/test_group.py new file mode 100644 index 000000000..0fc3ad74c --- /dev/null +++ b/traitsui/tests/test_group.py @@ -0,0 +1,537 @@ +# (C) Copyright 2004-2021 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" +Test cases for the UI object. +""" + +import unittest +import unittest.mock + +from traitsui.api import Group, Include, Item +from traitsui.group import ShadowGroup +from traitsui.tests._tools import BaseTestMixin + + +class TestGroup(BaseTestMixin, unittest.TestCase): + + def test_get_shadow_item(self): + """ + Given a group with an item + When get_shadow is called + Then it returns the group + """ + item = Item('x') + group = Group(item) + ui = unittest.mock.Mock() + + result = group.get_shadow(ui) + + self.assertIs(result, group) + ui.find.assert_not_called() + + def test_get_shadow_item_defined_when_true(self): + """ + Given a group with an item that has defined_when evaluate to True + When get_shadow is called + Then it returns the group + """ + item = Item('x', defined_when="True") + group = Group(item) + ui = unittest.mock.Mock(**{'eval_when.return_value': True}) + + result = group.get_shadow(ui) + + self.assertIs(result, group) + ui.find.assert_not_called() + ui.eval_when.assert_called_once() + + def test_get_shadow_item_defined_when_false(self): + """ + Given a group with an item that has defined_when evaluate to False + When get_shadow is called + Then it returns a shadow group with no items + """ + item = Item('x', defined_when="False") + group = Group(item) + ui = unittest.mock.Mock(**{'eval_when.return_value': False}) + + result = group.get_shadow(ui) + + self.assertIsInstance(result, ShadowGroup) + self.assertIs(result.shadow, group) + self.assertEqual(len(result.content), 0) + self.assertEqual(result.groups, 0) + ui.find.assert_not_called() + ui.eval_when.assert_called_once() + + def test_get_shadow_sub_group(self): + """ + Given a group with a sub-group + When get_shadow is called + Then it returns the group + """ + sub_group = Group(Item('x')) + group = Group(sub_group) + ui = unittest.mock.Mock() + + result = group.get_shadow(ui) + + self.assertIs(result, group) + ui.find.assert_not_called() + + def test_get_shadow_sub_group_recurses(self): + """ + Given a group with a sub-group + which returns a ShadowGroup from get_shadow + When get_shadow is called + Then it returns a shadow group with a shadow group for the subgroup + """ + sub_group = Group(Item('x', defined_when="False")) + group = Group(sub_group) + ui = unittest.mock.Mock(**{'eval_when.return_value': False}) + + result = group.get_shadow(ui) + + self.assertIsInstance(result, ShadowGroup) + self.assertIs(result.shadow, group) + self.assertEqual(len(result.content), 1) + shadow_subgroup = result.content[0] + self.assertIsInstance(shadow_subgroup, ShadowGroup) + self.assertIs(shadow_subgroup.shadow, sub_group) + self.assertEqual(result.groups, 1) + ui.find.assert_not_called() + + def test_get_shadow_sub_group_defined_when_true(self): + """ + Given a group with a sub-group that has defined_when evaluate to True + When get_shadow is called + Then it returns the group. + """ + sub_group = Group(Item('x'), defined_when="True") + group = Group(sub_group) + ui = unittest.mock.Mock(**{'eval_when.return_value': True}) + + result = group.get_shadow(ui) + + self.assertIs(result, group) + ui.find.assert_not_called() + ui.eval_when.assert_called_once() + + def test_get_shadow_sub_group_defined_when_false(self): + """ + Given a group with a sub-group that has defined_when evaluate to False + When get_shadow is called + Then it returns a shadow group with a shadow group for the sub-group + """ + sub_group = Group(Item('x'), defined_when="False") + group = Group(sub_group) + ui = unittest.mock.Mock(**{'eval_when.return_value': False}) + + result = group.get_shadow(ui) + + self.assertIsInstance(result, ShadowGroup) + self.assertIs(result.shadow, group) + self.assertEqual(len(result.content), 0) + self.assertEqual(result.groups, 0) + ui.find.assert_not_called() + ui.eval_when.assert_called_once() + + def test_get_shadow_include_none(self): + """ + Given a group with an include and the include resolves to None + When get_shadow is called + Then it returns a shadow group with no content + """ + group = Group(Include('test_include')) + ui = unittest.mock.Mock(**{'find.return_value': None}) + + result = group.get_shadow(ui) + + self.assertIsInstance(result, ShadowGroup) + self.assertIs(result.shadow, group) + self.assertEqual(len(result.content), 0) + self.assertEqual(result.groups, 0) + ui.find.assert_called_once() + + def test_get_shadow_include_item(self): + """ + Given a group with an include and the include resolves to an item + When get_shadow is called + Then it returns a shadow group with the same item + """ + include_group = Group(Include('test_include')) + item = Item('x') + ui = unittest.mock.Mock(**{'find.return_value': item}) + + result = include_group.get_shadow(ui) + + self.assertIsInstance(result, ShadowGroup) + self.assertIs(result.shadow, include_group) + self.assertEqual(len(result.content), 1) + self.assertIs(result.content[0], item) + self.assertEqual(result.groups, 0) + ui.find.assert_called_once() + + def test_get_shadow_include_sub_group(self): + """ + Given a group with an include and the include resolves to a group + When get_shadow is called + Then it returns a shadow group containing the subgroup + """ + sub_group = Group(Item('x')) + group = Group(Include('test_include')) + ui = unittest.mock.Mock(**{'find.return_value': sub_group}) + + result = group.get_shadow(ui) + + self.assertIsInstance(result, ShadowGroup) + self.assertIs(result.shadow, group) + self.assertEqual(len(result.content), 1) + self.assertIs(result.content[0], sub_group) + self.assertEqual(result.groups, 1) + ui.find.assert_called_once() + + def test_get_shadow_include_sub_group_defined_when_true(self): + """ + Given a group with an include and the include resolves to a group + that has defined_when evaluate to True + When get_shadow is called + Then it returns a shadow group containing the subgroup + """ + sub_group = Group(Item('x'), defined_when="True") + group = Group(Include('test_include')) + ui = unittest.mock.Mock(**{ + 'find.return_value': sub_group, + 'eval_when.return_value': True, + }) + + result = group.get_shadow(ui) + + self.assertIsInstance(result, ShadowGroup) + self.assertIs(result.shadow, group) + self.assertEqual(len(result.content), 1) + self.assertIs(result.content[0], sub_group) + self.assertEqual(result.groups, 1) + ui.find.assert_called_once() + ui.eval_when.assert_called_once() + + def test_get_shadow_include_sub_group_defined_when_false(self): + """ + Given a group with an include and the include resolves to a group + that has defined_when evaluate to True + When get_shadow is called + Then it returns a shadow group with a shadow group for the sub-group + """ + sub_group = Group(Item('x'), defined_when="False") + group = Group(Include('test_include')) + ui = unittest.mock.Mock(**{ + 'find.return_value': sub_group, + 'eval_when.return_value': False, + }) + + result = group.get_shadow(ui) + + self.assertIsInstance(result, ShadowGroup) + self.assertIs(result.shadow, group) + self.assertEqual(len(result.content), 0) + self.assertEqual(result.groups, 0) + ui.find.assert_called_once() + ui.eval_when.assert_called_once() + + def test_get_content_all_items(self): + """ + Given a Group with only Items + When get_content is called + Then it returns the list of Items + """ + item_x = Item('x') + item_y = Item('y') + group = Group(item_x, item_y) + + result = group.get_content() + + self.assertEqual(len(result), 2) + self.assertIs(result[0], item_x) + self.assertIs(result[1], item_y) + + def test_get_content_all_subgroups_allow_groups(self): + """ + Given a Group with only Groups + When get_content is called with allow_groups + Then it returns the list of Groups + """ + item_x = Item('x') + group_x = Group(item_x) + item_y = Item('y') + group_y = Group(item_y) + group = Group(group_x, group_y) + + result = group.get_content() + + self.assertEqual(len(result), 2) + self.assertIs(result[0], group_x) + self.assertIs(result[1], group_y) + + def test_get_content_mixed_allow_groups(self): + """ + Given a Group with a mixture of Groups and Items + When get_content is called with allow_groups + Then it assembles runs of items into ShadowGroups + """ + item_x = Item('x') + group_x = Group(item_x) + item_y = Item('y') + group_y = Group(item_y) + item_z = Item('z') + group = Group(group_x, item_z, group_y) + + result = group.get_content() + + self.assertEqual(len(result), 3) + self.assertIs(result[0], group_x) + self.assertIsInstance(result[1], ShadowGroup) + shadow_group_z = result[1] + self.assertIs(shadow_group_z.shadow, group) + self.assertEqual(len(shadow_group_z.content), 1) + self.assertIs(shadow_group_z.content[0], item_z) + self.assertIs(result[2], group_y) + + def test_get_content_mixed_allow_groups_layout_not_normal(self): + """ + Given a Group with a mixture of Groups and Items and non-normal layout + When get_content is called with allow_groups + Then it returns the contents as-is + """ + item_x = Item('x') + group_x = Group(item_x) + item_y = Item('y') + group_y = Group(item_y) + item_z = Item('z') + group = Group(group_x, item_z, group_y, layout='tabbed') + + result = group.get_content() + + self.assertEqual(len(result), 3) + self.assertIs(result[0], group_x) + self.assertIs(result[1], item_z) + self.assertIs(result[2], group_y) + + def test_get_content_all_subgroups_allow_groups_false(self): + """ + Given a Group with only Groups + When get_content is called with allow_groups False + Then it returns the flattened list of items. + """ + item_x = Item('x') + group_x = Group(item_x) + item_y = Item('y') + group_y = Group(item_y) + group = Group(group_x, group_y) + + result = group.get_content(False) + + self.assertEqual(len(result), 2) + self.assertIs(result[0], item_x) + self.assertIs(result[1], item_y) + + def test_get_content_mixed_allow_groups_false(self): + """ + Given a Group with a mix of Groups and items + When get_content is called with allow_groups False + Then it returns the flattened list of items. + """ + item_x = Item('x') + group_x = Group(item_x) + item_y = Item('y') + group_y = Group(item_y) + item_z = Item('z') + group = Group(group_x, item_z, group_y) + + result = group.get_content(False) + + self.assertEqual(len(result), 3) + self.assertIs(result[0], item_x) + self.assertIs(result[1], item_z) + self.assertIs(result[2], item_y) + + def test_groups_property(self): + item_x = Item('x') + group_x = Group(item_x) + item_y = Item('y') + group_y = Group(item_y) + item_z = Item('z') + group = Group(group_x, item_z, group_y) + + self.assertEqual(group.groups, 2) + + +class TestShadowGroup(BaseTestMixin, unittest.TestCase): + + def test_get_content_all_items(self): + """ + Given a ShadowGroup with only Items + When get_content is called + Then it returns the list of Items + """ + item_x = Item('x') + item_y = Item('y') + group = Group(item_x, item_y) + shadow_group = ShadowGroup( + shadow=group, + content=group.content, + ) + + result = shadow_group.get_content() + + self.assertEqual(len(result), 2) + self.assertIs(result[0], item_x) + self.assertIs(result[1], item_y) + + def test_get_content_all_subgroups_allow_groups(self): + """ + Given a ShadowGroup with only Groups and ShadowGroups + When get_content is called with allow_groups + Then it returns the list of Groups and ShadowGroups + """ + item_x = Item('x') + group_x = Group(item_x) + shadow_group_x = ShadowGroup( + shadow=group_x, + content=group_x.content, + ) + item_y = Item('y') + group_y = Group(item_y) + group = Group(group_x, group_y) + shadow_group = ShadowGroup( + shadow=group, + content=[shadow_group_x, group_y], + ) + + result = shadow_group.get_content() + + self.assertEqual(len(result), 2) + self.assertIs(result[0], shadow_group_x) + self.assertIs(result[1], group_y) + + def test_get_content_mixed_allow_groups(self): + """ + Given a ShadowGroup with a mix of Groups, ShadowGroups and Items + When get_content is called with allow_groups + Then it assembles runs of items into ShadowGroups + """ + item_x = Item('x') + group_x = Group(item_x) + shadow_group_x = ShadowGroup( + shadow=group_x, + content=group_x.content, + ) + item_y = Item('y') + group_y = Group(item_y) + item_z = Item('z') + group = Group(group_x, item_z, group_y) + shadow_group = ShadowGroup( + shadow=group, + content=[shadow_group_x, item_z, group_y], + ) + + result = shadow_group.get_content() + + self.assertEqual(len(result), 3) + self.assertIs(result[0], shadow_group_x) + self.assertIsInstance(result[1], ShadowGroup) + shadow_group_z = result[1] + self.assertIs(shadow_group_z.shadow, group) + self.assertEqual(len(shadow_group_z.content), 1) + self.assertIs(shadow_group_z.content[0], item_z) + self.assertIs(result[2], group_y) + + def test_get_content_mixed_allow_groups_layout_not_normal(self): + """ + Given a ShadowGroup with a mixture of Groups, ShadowGroups and Items + and non-normal layout + When get_content is called with allow_groups + Then it returns the contents as-is + """ + item_x = Item('x') + group_x = Group(item_x) + shadow_group_x = ShadowGroup( + shadow=group_x, + content=group_x.content, + ) + item_y = Item('y') + group_y = Group(item_y) + item_z = Item('z') + group = Group(group_x, item_z, group_y, layout='tabbed') + shadow_group = ShadowGroup( + shadow=group, + content=[shadow_group_x, item_z, group_y], + ) + + result = shadow_group.get_content() + + self.assertEqual(len(result), 3) + self.assertIs(result[0], shadow_group_x) + self.assertIs(result[1], item_z) + self.assertIs(result[2], group_y) + + def test_get_content_all_subgroups_allow_groups_false(self): + """ + Given a ShadowGroup with only Groups and ShadowGroups + When get_content is called with allow_groups False + Then it returns the flattened list of items. + """ + item_x = Item('x') + group_x = Group(item_x) + shadow_group_x = ShadowGroup( + shadow=group_x, + content=group_x.content, + ) + item_y = Item('y') + group_y = Group(item_y) + group = Group(group_x, group_y) + shadow_group = ShadowGroup( + shadow=group, + content=[shadow_group_x, group_y], + ) + + result = shadow_group.get_content(False) + + self.assertEqual(len(result), 2) + self.assertIs(result[0], item_x) + self.assertIs(result[1], item_y) + + def test_get_content_mixed_allow_groups_false(self): + """ + Given a ShadowGroup with a mix of Groups, ShadowGroups and items + When get_content is called with allow_groups False + Then it returns the flattened list of items. + """ + item_x = Item('x') + group_x = Group(item_x) + shadow_group_x = ShadowGroup( + shadow=group_x, + content=group_x.content, + ) + item_y = Item('y') + group_y = Group(item_y) + item_z = Item('z') + group = Group(group_x, item_z, group_y) + shadow_group = ShadowGroup( + shadow=group, + content=[shadow_group_x, item_z, group_y], + ) + + result = shadow_group.get_content(False) + + self.assertEqual(len(result), 3) + self.assertIs(result[0], item_x) + self.assertIs(result[1], item_z) + self.assertIs(result[2], item_y)