1# (C) Copyright 2007-2019 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
6# under the conditions described in the aforementioned license.  The license
7# is also available online at http://www.enthought.com/licenses/BSD.txt
8# Thanks for using Enthought open source!
9"""
10A service to enable UI interactions with the single project plugin.
11
12"""
13
14# Standard library imports.
15import logging
16import os
17import shutil
18
19# Enthought library imports
20from apptools.preferences.api import bind_preference
21from apptools.io.api import File
22from apptools.naming.api import Context
23from pyface.api import CANCEL, confirm, ConfirmationDialog, \
24    DirectoryDialog, error, FileDialog, information, NO, OK, YES
25from pyface.action.api import MenuManager
26from pyface.timer.api import do_later, Timer
27from traits.api import Any, Event, HasTraits, Instance, Int
28
29# Local imports.
30from .model_service import ModelService
31
32
33# Setup a logger for this module.
34logger = logging.getLogger(__name__)
35
36
37class UiService(HasTraits):
38    """
39    A service to enable UI interactions with the single project plugin.
40
41    """
42
43    ##########################################################################
44    # Attributes
45    ##########################################################################
46
47    #### public 'UiService' interface ########################################
48
49    # The manager of the default context menu
50    default_context_menu_manager = Instance(MenuManager)
51
52    # A reference to our plugin's model service.
53    model_service = Instance(ModelService)
54
55    # The project control (in our case a tree). This is created by the
56    # project view.  Provided here so that sub-classes may access it.
57    project_control = Any
58
59    # Fired when a new project has been created.  The value should be the
60    # project instance that was created.
61    project_created = Event
62
63    # A timer to implement automatic project saving.
64    timer = Instance(Timer)
65
66    # The interval (minutes)at which automatic saving should occur.
67    autosave_interval = Int(5)
68
69    ##########################################################################
70    # 'object' interface.
71    ##########################################################################
72
73    #### operator methods ####################################################
74
75    def __init__(self, model_service, menu_manager, **traits):
76        """
77        Constructor.
78
79        Extended to require a reference to the plugin's model service to create
80        an instance.
81
82        """
83
84        super(UiService, self).__init__(
85            model_service = model_service,
86            default_context_menu_manager = menu_manager,
87            **traits
88            )
89        try:
90            # Bind the autosave interval to the value specified in the
91            # single project preferences
92            p = self.model_service.preferences
93            bind_preference(self, 'autosave_interval', 5, p)
94        except:
95            logger.exception('Failed to bind autosave_interval in [%s] to '
96                             'preferences.' % self)
97
98        return
99
100
101    ##########################################################################
102    # 'UiService' interface.
103    ##########################################################################
104
105    #### public interface ####################################################
106
107    def close(self, event):
108        """
109        Close the current project.
110
111        """
112
113        # Ensure any current project is ready for this change.
114        if self.is_current_project_saved(event.window.control):
115
116            # If we have a current project, close it.
117            current = self.model_service.project
118            if current is not None:
119                logger.debug("Closing Project [%s]", current.name)
120                self.model_service.project = None
121
122        return
123
124
125    def create(self, event):
126        """
127        Create a new project.
128
129        """
130        # Ensure any current project is ready for this change.
131        if self.is_current_project_saved(event.window.control):
132
133            # Use the registered factory to create a new project
134            project = self.model_service.factory.create()
135            if project is not None:
136
137                # Allow the user to customize the new project
138                dialog = project.edit_traits(
139                    parent = event.window.control,
140                    # FIXME: Due to a bug in traits, using a wizard dialog
141                    # causes all of the Instance traits on the object being
142                    # edited to be replaced with new instances without any
143                    # listeners on those traits being called.  Since we can't
144                    # guarantee that our project's don't have Instance traits,
145                    # we can't use the wizard dialog type.
146                    #kind = 'wizard'
147                    kind = 'livemodal'
148                    )
149
150                # If the user closed the dialog with an ok, make it the
151                # current project.
152                if dialog.result:
153                    logger.debug("Created Project [%s]", project.name)
154                    self.model_service.project = project
155                    self.project_created = project
156
157        return
158
159
160    def display_default_context_menu(self, parent, event):
161        """
162        Display the default context menu for the plugin's ui.  This is the
163        context menu used when neither a project nor the project's contents
164        are right-clicked.
165
166        """
167
168        # Determine the current workbench window.  This should be safe since
169        # we're only building a context menu when the user clicked on a
170        # control that is contained in a window.
171        workbench = self.model_service.application.get_service('envisage.ui.workbench.workbench.Workbench')
172        window = workbench.active_window
173
174        # Build our menu
175        from envisage.workbench.action.action_controller import \
176            ActionController
177        menu = self.default_context_menu_manager.create_menu(parent,
178            controller = ActionController(window=window))
179
180        # Popup the menu (if an action is selected it will be performed
181        # before before 'PopupMenu' returns).
182        if menu.GetMenuItemCount() > 0:
183            menu.show(event.x, event.y)
184
185        return
186
187
188    def delete_selection(self):
189        """
190        Delete the current selection within the current project.
191
192        """
193
194        # Only do something if we have a current project and a non-empty
195        # selection
196        current = self.model_service.project
197        selection = self.model_service.selection[:]
198        if current is not None and len(selection) > 0:
199            logger.debug('Deleting selection from Project [%s]', current)
200
201            # Determine the context for the current project.  Raise an error
202            # if we can't treat it as a context as then we don't know how
203            # to delete anything.
204            context = self._get_context_for_object(current)
205            if context is None:
206                raise Exception('Could not treat Project ' + \
207                    '[%s] as a context' % current)
208
209            # Filter out any objects in the selection that can NOT be deleted.
210            deletables = []
211            for item in selection:
212                rt = self._get_resource_type_for_object(item.obj)
213                nt = rt.node_type
214                if nt.can_delete(item):
215                    deletables.append(item)
216                else:
217                    logger.debug('Node type reports selection item [%s] is '
218                        'not deletable.', nt)
219
220            if deletables != []:
221                # Confirm the delete operation with the user
222                names = '\n\t'.join([b.name for b in deletables])
223                message = ('You are about to delete the following selected '
224                    'items:\n\t%s\n\n'
225                    'Are you sure?') % names
226                title = 'Delete Selected Items?'
227                action = confirm(None, message, title)
228                if action == YES:
229
230                    # Unbind all the deletable nodes
231                    if len(deletables) > 0:
232                        self._unbind_nodes(context, deletables)
233
234        return
235
236
237    def is_current_project_saved(self, parent_window):
238        """
239        Give the user the option to save any modifications to the current
240        project prior to closing it.
241
242        If the user wanted to cancel the closing of the current project,
243        this method returns False.  Otherwise, it returns True.
244
245        """
246
247        # The default is the user okay'd the closing of the project
248        result = True
249
250        # If the current project is dirty, handle that now by challenging the
251        # user for how they want to handle them.
252        current = self.model_service.project
253        if not(self._get_project_state(current)):
254            dialog = ConfirmationDialog(
255                parent  = parent_window,
256                cancel  = True,
257                title   = 'Unsaved Changes',
258                message = 'Do you want to save the changes to project "%s"?' \
259                    % (current.name),
260                )
261            action = dialog.open()
262            if action == CANCEL:
263                result = False
264            elif action == YES:
265                result = self._save(current, parent_window)
266            elif action == NO:
267                # Delete the autosaved file as the user does not wish to
268                # retain the unsaved changes.
269                self._clean_autosave_location(current.location.strip())
270        return result
271
272
273    def listen_for_application_exit(self):
274        """
275        Ensure that we get notified of any attempts to, and thus have a chance
276        to veto, the closing of the application.
277
278        FIXME: Normally this should be called during startup of this
279        plugin, however, Envisage won't let us find the workbench service
280        then because we've made a contribution to its extension points
281        and it insists on starting us first.
282
283        """
284
285        workbench = self.model_service.application.get_service('envisage.ui.workbench.workbench.Workbench')
286        workbench.on_trait_change(self._workbench_exiting, 'exiting')
287
288        return
289
290
291    def open(self, event):
292        """
293        Open a project.
294
295        """
296        # Ensure any current project is ready for this change.
297        if self.is_current_project_saved(event.window.control):
298
299            # Query the user for the location of the project to be opened.
300            path = self._show_open_dialog(event.window.control)
301            if path is not None:
302                logger.debug("Opening project from location [%s]", path)
303
304                project = self.model_service.factory.open(path)
305                if project is not None:
306                    logger.debug("Opened Project [%s]", project.name)
307                    self.model_service.project = project
308                else:
309                    msg = 'Unable to open %s as a project.' % path
310                    error(event.window.control, msg, title='Project Open Error')
311
312        return
313
314
315    def save(self, event):
316        """
317        Save a project.
318
319        """
320
321        current = self.model_service.project
322        if current is not None:
323            self._save(current, event.window.control)
324
325        return
326
327
328    def save_as(self, event):
329        """
330        Save the current project to a different location.
331
332        """
333
334        current = self.model_service.project
335        if current is not None:
336            self._save(current, event.window.control, prompt_for_location=True)
337
338        return
339
340
341    #### protected interface #################################################
342
343    def _auto_save(self, project):
344        """
345
346        Called periodically by the timer's Notify function to automatically
347        save the current project.
348        The auto-saved project has the extension '.autosave'.
349
350        """
351        # Save the project only if it has been modified.
352        if project.dirty and project.is_save_as_allowed:
353            location = project.location.strip()
354            if not(location is None or len(location) < 1):
355                autosave_loc = self._get_autosave_location(location)
356                try:
357                    # We do not want the project's location and name to be
358                    # updated.
359                    project.save(autosave_loc, overwrite=True,
360                                 autosave=True)
361                    msg = '[%s] auto-saved to [%s]' % (project,
362                                                       autosave_loc)
363                    logger.debug(msg)
364                except:
365                    logger.exception('Error auto-saving project [%s]'% project)
366            else:
367                logger.exception('Error auto-saving project [%s] in '
368                                 'location %s' % (project, location))
369        return
370
371
372    def _clean_autosave_location(self, location):
373        """
374        Removes any existing autosaved files or directories for the project
375        at the specified location.
376
377        """
378        autosave_loc = self._get_autosave_location(location)
379        if os.path.exists(autosave_loc):
380            self.model_service.clean_location(autosave_loc)
381        return
382
383
384    def _get_autosave_location(self, location):
385        """
386        Returns the path for auto-saving the project in location.
387
388        """
389        return os.path.join(os.path.dirname(location),
390                            os.path.basename(location) + '.autosave')
391
392
393    def _get_context_for_object(self, obj):
394        """
395        Return the context for the specified object.
396
397        """
398
399        if isinstance(obj, Context):
400            context = obj
401        else:
402            context = None
403            resource_type = self._get_resource_type_for_object(obj)
404            if resource_type is not None:
405                factory = resource_type.context_adapter_factory
406                if factory is not None:
407                    # FIXME: We probably should use a real environment and
408                    # context (parent context?)
409                    context = factory.adapt(obj, Context, {}, None)
410
411        return context
412
413
414    def _get_resource_type_for_object(self, obj):
415        """
416        Return the resource type for the specified object.
417
418        If no type could be found, returns None.
419
420        """
421
422        resource_manager = self.model_service.resource_manager
423        return resource_manager.get_type_of(obj)
424
425
426    def _get_project_state(self, project):
427        """ Returns True if the project is clean: i.e., the dirty flag is
428        False and all autosaved versions have been deleted from the filesystem.
429
430        """
431
432        result = True
433        if project is not None:
434            autosave_loc = self._get_autosave_location(
435                project.location.strip())
436            if project.dirty or os.path.exists(autosave_loc):
437                result = False
438        return result
439
440
441    def _get_user_location(self, project, parent_window):
442        """
443        Prompt the user for a new location for the specified project.
444
445        Returns the chosen location or, if the user cancelled, an empty
446        string.
447
448        """
449
450        # The dialog to use depends on whether we're prompting for a file or
451        # a directory.
452        if self.model_service.are_projects_files():
453            dialog = FileDialog(parent = parent_window,
454                title = 'Save Project As',
455                default_path = project.location,
456                action = 'save as',
457                )
458            title_type = 'File'
459        else:
460            dialog = DirectoryDialog(parent = parent_window,
461                message = 'Choose a Directory for the Project',
462                default_path = project.location,
463                action = 'open'
464                )
465            title_type = 'Directory'
466
467        # Prompt the user for a new location and then validate we're not
468        # overwriting something without getting confirmation from the user.
469        result = ""
470        while(dialog.open() == OK):
471            location = dialog.path.strip()
472
473            # If the chosen location doesn't exist yet, we're set.
474            if not os.path.exists(location):
475                logger.debug('Location [%s] does not exist yet.', location)
476                result = location
477                break
478
479            # Otherwise, confirm with the user that they want to overwrite the
480            # existing files or directories.  If they don't want to, then loop
481            # back and prompt them for a new location.
482            else:
483                logger.debug('Location [%s] exists.  Prompting for overwrite '
484                    'permission.', location)
485                message = 'Overwrite %s?' % location
486                title = 'Project %s Exists' % title_type
487                action = confirm(parent_window, message, title)
488                if action == YES:
489
490                    # Only use the location if we successfully remove the
491                    # existing files or directories at that location.
492                    try:
493                        self.model_service.clean_location(location)
494                        result = location
495                        break
496
497                    # Otherwise, display the remove error to the user and give
498                    # them another chance to pick another location
499                    except Exception as e:
500                        msg = str(e)
501                        title = 'Unable To Overwrite %s' % location
502                        information(parent_window, msg, title)
503
504        logger.debug('Returning user location [%s]', result)
505        return result
506
507
508    def _restore_from_autosave(self, project, autosave_loc):
509        """ Restores the project from the version saved in autosave_loc.
510
511        """
512
513        workbench = self.model_service.application.get_service(
514            'envisage.ui.workbench.workbench.Workbench')
515        window = workbench.active_window
516        app_name = workbench.branding.application_name
517        message = ('The app quit unexpectedly when [%s] was being modified.\n'
518                   'An autosaved version of this project exists.\n'
519                   'Do you want to restore the project from the '
520                   'autosaved version ?' % project.name)
521        title = '%s-%s' % (app_name, project.name)
522        action = confirm(window.control, message, title, cancel=True,
523                         default=YES)
524        if action == YES:
525            try:
526                saved_project = self.model_service.factory.open(autosave_loc)
527                if saved_project is not None:
528                    # Copy over the autosaved version to the current project's
529                    # location, switch the model service's project, and delete
530                    # the autosaved version.
531                    loc = project.location.strip()
532                    saved_project.save(loc, overwrite=True)
533                    self.model_service.clean_location(autosave_loc)
534                    self.model_service.project = saved_project
535                else:
536                    logger.debug('No usable project found in [%s].' %
537                                 autosave_loc)
538            except:
539                logger.exception(
540                    'Unable to restore project from [%s]' %
541                    autosave_loc)
542        self._start_timer(self.model_service.project)
543
544        return
545
546
547    def _save(self, project, parent_window, prompt_for_location=False):
548        """
549        Save the specified project.  If *prompt_for_location* is True,
550        or the project has no known location, then the user is prompted to
551        provide a location to save to.
552
553        Returns True if the project was saved successfully, False if not.
554
555        """
556
557        location = project.location.strip()
558
559        # If the project's existing location is valid, check if there are any
560        # autosaved versions.
561        autosave_loc = ''
562        if location is not None and os.path.exists(location):
563            autosave_loc = self._get_autosave_location(location)
564
565        # Ask the user to provide a location if we were told to do so or
566        # if the project has no existing location.
567        if prompt_for_location or location is None or len(location) < 1:
568            location = self._get_user_location(project, parent_window)
569            # Rename any existing autosaved versions to the new project
570            # location.
571            if location is not None and len(location) > 0:
572                self._clean_autosave_location(location)
573                new_autosave_loc = self._get_autosave_location(location)
574                if os.path.exists(autosave_loc):
575                    shutil.move(autosave_loc, new_autosave_loc)
576
577        # If we have a location to save to, try saving the project.
578        if location is not None and len(location) > 0:
579            try:
580                project.save(location)
581                saved = True
582                msg = '"%s" saved to %s' % (project.name, project.location)
583                information(parent_window, msg, 'Project Saved')
584                logger.debug(msg)
585
586            except Exception as e:
587                saved = False
588                logger.exception('Error saving project [%s]', project)
589                error(parent_window, str(e), title='Save Error')
590        else:
591            saved = False
592
593        # If the save operation was successful, delete any autosaved files that
594        # exist.
595        if saved:
596            self._clean_autosave_location(location)
597        return saved
598
599
600    def _show_open_dialog(self, parent):
601        """
602        Show the dialog to open a project.
603
604        """
605
606        # Determine the starting point for browsing.  It is likely most
607        # projects will be stored in the default path used when creating new
608        # projects.
609        default_path = self.model_service.get_default_path()
610        project_class = self.model_service.factory.PROJECT_CLASS
611
612        if self.model_service.are_projects_files():
613            dialog = FileDialog(parent=parent, default_directory=default_path,
614                title='Open Project')
615            if dialog.open() == OK:
616                path = dialog.path
617            else:
618                path = None
619        else:
620            dialog = DirectoryDialog(parent=parent, default_path=default_path,
621                message='Open Project')
622            if dialog.open() == OK:
623                path = project_class.get_pickle_filename(dialog.path)
624                if File(path).exists:
625                    path = dialog.path
626                else:
627                    error(parent, 'Directory does not contain a recognized '
628                        'project')
629                    path = None
630            else:
631                path = None
632
633        return path
634
635
636    def _start_timer(self, project):
637        """
638        Resets the timer to work on auto-saving the current project.
639
640        """
641
642        if self.timer is None:
643            if self.autosave_interval > 0:
644                # Timer needs the interval in millisecs
645                self.timer = Timer(self.autosave_interval*60000,
646                                   self._auto_save, project)
647        return
648
649
650    def _unbind_nodes(self, context, nodes):
651        """
652        Unbinds all of the specified nodes that can be found within this
653        context or any of its sub-contexts.
654
655        This uses a breadth first algorithm on the assumption that the
656        user will have likely selected peer nodes within a sub-context
657        that isn't the deepest context.
658
659        """
660
661        logger.debug('Unbinding nodes [%s] from context [%s] within '
662            'UiService [%s]', nodes, context, self)
663
664        # Iterate through all of the selected nodes looking for ones who's
665        # name is within our context.
666        context_names = context.list_names()
667        for node in nodes[:]:
668            if node.name in context_names:
669
670                # Ensure we've found a matching node by matching the objects
671                # as well.
672                binding = context.lookup_binding(node.name)
673                if id(node.obj) == id(binding.obj):
674
675                    # Remove the node from the context -AND- from the list of
676                    # nodes that are still being searched for.
677                    context.unbind(node.name)
678                    nodes.remove(node)
679
680                    # Stop if we've unbound the last node
681                    if len(nodes) < 1:
682                        break
683
684        # If we haven't unbound the last node, then search any sub-contexts
685        # for more nodes to unbind.
686        else:
687
688            # Build a list of all current sub-contexts of this context.
689            subs = []
690            for name in context.list_names():
691                if context.is_context(name):
692                    obj = context.lookup_binding(name).obj
693                    sub_context = self._get_context_for_object(obj)
694                    if sub_context is not None:
695                        subs.append(sub_context)
696
697            # Iterate through each sub-context, stopping as soon as possible
698            # if we've run out of nodes.
699            for sub in subs:
700                self._unbind_nodes(sub, nodes)
701                if len(nodes) < 1:
702                    break
703
704
705    def _workbench_exiting(self, event):
706        """
707        Handle the workbench polling to see if it can exit and shutdown the
708        application.
709
710        """
711
712        logger.debug('Detected workbench closing event in [%s]', self)
713        # Determine if the current project is dirty, or if an autosaved file
714        # exists for this project (i.e., the project has changes which were
715        # captured in the autosave operation but were not saved explicitly by
716        # the user).  If so, let the user
717        # decide whether to veto the closing event, save the project, or
718        # ignore the dirty state.
719        current = self.model_service.project
720
721        if not(self._get_project_state(current)):
722            # Find the active workbench window to be our dialog parent and
723            # the application name to use in our dialog title.
724            workbench = self.model_service.application.get_service('envisage.ui.workbench.workbench.Workbench')
725            window = workbench.active_window
726            app_name = workbench.branding.application_name
727
728            # Show a confirmation dialog to the user.
729            message = 'Do you want to save changes before exiting?'
730            title = '%s - %s' % (current.name, app_name)
731            action = confirm(window.control, message, title, cancel=True,
732                default=YES)
733            if action == YES:
734                # If the save is successful, the autosaved file is deleted.
735                if not self._save(current, window.control):
736                    event.veto = True
737            elif action == NO:
738                # Delete the autosaved file as the user does not wish to
739                # retain the unsaved changes.
740                self._clean_autosave_location(current.location.strip())
741            elif action == CANCEL:
742                event.veto = True
743
744
745    #### Trait change handlers ###############################################
746
747    def _autosave_interval_changed(self, old, new):
748        """
749        Restarts the timer when the autosave interval changes.
750
751        """
752
753        self.timer = None
754        if new > 0 and self.model_service.project is not None:
755            self._start_timer(self.model_service.project)
756        return
757
758
759    def _project_changed_for_model_service(self, object, name, old, new):
760        """
761        Detects if an autosaved version exists for the project, and displays
762        a dialog to confirm restoring the project from the autosaved version.
763
764        """
765
766        if old is not None:
767            self.timer = None
768        if new is not None:
769            # Check if an autosaved version exists and if so, display a dialog
770            # asking if the user wishes to restore the project from the
771            # autosaved version.
772            # Note: An autosaved version should exist only if the app crashed
773            # unexpectedly. Regular exiting of the workbench should cause the
774            # autosaved version to be deleted.
775            autosave_loc = self._get_autosave_location(new.location.strip())
776            if (os.path.exists(autosave_loc)):
777                # Issue a do_later command here so as to allow time for the
778                # project view to be updated first to reflect the current
779                # project's state.
780                do_later(self._restore_from_autosave, new,
781                         autosave_loc)
782            else:
783                self._start_timer(new)
784        return
785
786#### EOF #####################################################################
787
788