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