1# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX
2# All rights reserved.
3#
4# This software is provided without warranty under the terms of the BSD
5# license included in LICENSE.txt and may be redistributed only under
6# the conditions described in the aforementioned license. The license
7# is also available online at http://www.enthought.com/licenses/BSD.txt
8#
9# Thanks for using Enthought open source!
10
11from collections import defaultdict
12import logging
13
14
15from pyface.action.api import ActionController, ActionManager
16from traits.api import HasTraits, Instance
17
18
19from pyface.tasks.task import Task
20from pyface.tasks.topological_sort import before_after_sort
21from pyface.tasks.action.schema import Schema, ToolBarSchema
22from pyface.tasks.action.schema_addition import SchemaAddition
23
24# Logging.
25logger = logging.getLogger(__name__)
26
27
28class TaskActionManagerBuilder(HasTraits):
29    """ Builds menu bars and tool bars from menu bar and tool bar schema, along
30    with any additions provided by the task.
31    """
32
33    # The controller to assign to the menubar and toolbars.
34    controller = Instance(ActionController)
35
36    # The Task to build menubars and toolbars for.
37    task = Instance(Task)
38
39    # ------------------------------------------------------------------------
40    # 'TaskActionManagerBuilder' interface.
41    # ------------------------------------------------------------------------
42
43    def create_action_manager(self, schema):
44        """ Create a manager for the given schema using the task's additions.
45        """
46        additions_map = defaultdict(list)
47        for addition in self.task.extra_actions:
48            if addition.path:
49                additions_map[addition.path].append(addition)
50
51        manager = self._create_action_manager_recurse(schema, additions_map)
52        manager.controller = self.controller
53        return manager
54
55    def create_menu_bar_manager(self):
56        """ Create a menu bar manager from the task's menu bar schema and
57            additions.
58        """
59        if self.task.menu_bar:
60            return self.create_action_manager(self.task.menu_bar)
61        return None
62
63    def create_tool_bar_managers(self):
64        """ Create tool bar managers from the tasks's tool bar schemas and
65            additions.
66        """
67        schemas = self.task.tool_bars[:]
68        for addition in self.task.extra_actions:
69            if not addition.path:
70                schema = addition.factory()
71                if isinstance(schema, ToolBarSchema):
72                    schemas.append(schema)
73                else:
74                    logger.error(
75                        "Invalid top-level schema addition: %r. Only "
76                        "ToolBar schemas can be path-less.",
77                        schema,
78                    )
79        return [
80            self.create_action_manager(schema)
81            for schema in self._get_ordered_schemas(schemas)
82        ]
83
84    def prepare_item(self, item, path):
85        """ Called immediately after a concrete Pyface item has been created
86        (or, in the case of items that are not produced from schemas,
87        immediately before they are processed).
88
89        This hook can be used to perform last-minute transformations or
90        configuration. Returns a concrete Pyface item.
91        """
92        return item
93
94    # ------------------------------------------------------------------------
95    # Private interface.
96    # ------------------------------------------------------------------------
97
98    def _get_ordered_schemas(self, schemas):
99        begin = []
100        middle = []
101        end = []
102
103        for schema in schemas:
104            absolute_position = getattr(schema, "absolute_position", None)
105            if absolute_position is None:
106                middle.append(schema)
107            elif absolute_position == "last":
108                end.append(schema)
109            else:
110                begin.append(schema)
111
112        schemas = (
113            before_after_sort(begin)
114            + before_after_sort(middle)
115            + before_after_sort(end)
116        )
117        return schemas
118
119    def _group_items_by_id(self, items):
120        """ Group a list of action items by their ID.
121
122        Action items are Schemas and Groups, MenuManagers, etc.
123
124        Return a dictionary {item_id: list_of_items}, and a list containing
125        all the ids ordered by their appearance in the `all_items` list. The
126        ordered IDs are used as a replacement for an ordered dictionary, to
127        keep compatibility with Python <2.7 .
128
129        """
130
131        ordered_items_ids = []
132        id_to_items = defaultdict(list)
133
134        for item in items:
135            if item.id not in id_to_items:
136                ordered_items_ids.append(item.id)
137            id_to_items[item.id].append(item)
138
139        return id_to_items, ordered_items_ids
140
141    def _group_items_by_class(self, items):
142        """ Group a list of action items by their class.
143
144        Action items are Schemas and Groups, MenuManagers, etc.
145
146        Return a dictionary {item_class: list_of_items}, and a list containing
147        all the classes ordered by their appearance in the `all_items` list.
148        The ordered classes are used as a replacement for an ordered
149        dictionary, to keep compatibility with Python <2.7 .
150
151        """
152
153        ordered_items_class = []
154        class_to_items = defaultdict(list)
155
156        for item in items:
157            if item.__class__ not in class_to_items:
158                ordered_items_class.append(item.__class__)
159            class_to_items[item.__class__].append(item)
160
161        return class_to_items, ordered_items_class
162
163    def _unpack_schema_additions(self, items):
164        """ Unpack additions, since they may themselves be schemas. """
165
166        unpacked_items = []
167
168        for item in items:
169            if isinstance(item, SchemaAddition):
170                unpacked_items.append(item.factory())
171            else:
172                unpacked_items.append(item)
173
174        return unpacked_items
175
176    def _merge_items_with_same_path(self, id_to_items, ordered_items_ids):
177        """ Merge items with the same path if possible.
178
179        Items must be subclasses of `Schema` and they must be instances of
180        the same class to be merged.
181
182        """
183
184        merged_items = []
185        for item_id in ordered_items_ids:
186            items_with_same_id = id_to_items[item_id]
187
188            # Group items by class.
189            class_to_items, ordered_items_class = self._group_items_by_class(
190                items_with_same_id
191            )
192
193            for items_class in ordered_items_class:
194                items_with_same_class = class_to_items[items_class]
195
196                if len(items_with_same_class) == 1:
197                    merged_items.extend(items_with_same_class)
198
199                else:
200                    # Only schemas can be merged.
201                    if issubclass(items_class, Schema):
202                        # Merge into a single schema.
203                        items_content = sum(
204                            (item.items for item in items_with_same_class), []
205                        )
206
207                        merged_item = items_with_same_class[0].clone_traits()
208                        merged_item.items = items_content
209                        merged_items.append(merged_item)
210
211                    else:
212                        merged_items.extend(items_with_same_class)
213
214        return merged_items
215
216    def _preprocess_schemas(self, schema, additions, path):
217        """ Sort and merge a schema and a set of schema additions. """
218
219        # Determine the order of the items at this path.
220        if additions[path]:
221            all_items = self._get_ordered_schemas(
222                schema.items + additions[path]
223            )
224        else:
225            all_items = schema.items
226
227        unpacked_items = self._unpack_schema_additions(all_items)
228
229        id_to_items, ordered_items_ids = self._group_items_by_id(
230            unpacked_items
231        )
232
233        merged_items = self._merge_items_with_same_path(
234            id_to_items, ordered_items_ids
235        )
236
237        return merged_items
238
239    def _create_action_manager_recurse(self, schema, additions, path=""):
240        """ Recursively create a manager for the given schema and additions map.
241
242        Items with the same path are merged together in a single entry if
243        possible (i.e., if they have the same class).
244
245        When a list of items is merged, their children are added to a clone
246        of the first item in the list. As a consequence, traits like menu
247        names etc. are inherited from the first item.
248
249        """
250
251        # Compute the new action path.
252        if path:
253            path = path + "/" + schema.id
254        else:
255            path = schema.id
256
257        preprocessed_items = self._preprocess_schemas(schema, additions, path)
258
259        # Create the actual children by calling factory items.
260        children = []
261        for item in preprocessed_items:
262            if isinstance(item, Schema):
263                item = self._create_action_manager_recurse(
264                    item, additions, path
265                )
266            else:
267                item = self.prepare_item(item, path + "/" + item.id)
268
269            if isinstance(item, ActionManager):
270                # Give even non-root action managers a reference to the
271                # controller so that custom Groups, MenuManagers, etc. can get
272                # access to their Tasks.
273                item.controller = self.controller
274
275            children.append(item)
276
277        # Finally, create the pyface.action instance for this schema.
278        return self.prepare_item(schema.create(children), path)
279
280    # Trait initializers ---------------------------------------------------
281
282    def _controller_default(self):
283        from .task_action_controller import TaskActionController
284
285        return TaskActionController(task=self.task)
286