1# -*- coding: utf-8 -*- 2 3# Copyright (c) 2002 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> 4# 5 6""" 7Module implementing the baseclass for the various project browsers. 8""" 9 10import os 11import contextlib 12 13from PyQt5.QtCore import ( 14 QModelIndex, pyqtSignal, Qt, QCoreApplication, QItemSelectionModel, 15 QItemSelection, QElapsedTimer 16) 17from PyQt5.QtWidgets import ( 18 QTreeView, QApplication, QMenu, QDialog, QAbstractItemView 19) 20 21from E5Gui.E5Application import e5App 22from E5Gui import E5MessageBox 23from E5Gui.E5OverrideCursor import E5OverrideCursor 24 25from UI.Browser import Browser 26from UI.BrowserModel import BrowserDirectoryItem, BrowserFileItem 27 28from .ProjectBrowserModel import ( 29 ProjectBrowserSimpleDirectoryItem, ProjectBrowserDirectoryItem, 30 ProjectBrowserFileItem 31) 32from .ProjectBrowserSortFilterProxyModel import ( 33 ProjectBrowserSortFilterProxyModel 34) 35 36 37class ProjectBaseBrowser(Browser): 38 """ 39 Baseclass implementing common functionality for the various project 40 browsers. 41 42 @signal closeSourceWindow(str) emitted to close a source file 43 """ 44 closeSourceWindow = pyqtSignal(str) 45 46 def __init__(self, project, type_, parent=None): 47 """ 48 Constructor 49 50 @param project reference to the project object 51 @param type_ project browser type (string) 52 @param parent parent widget of this browser 53 """ 54 QTreeView.__init__(self, parent) 55 56 self.project = project 57 58 self._model = project.getModel() 59 self._sortModel = ProjectBrowserSortFilterProxyModel(type_) 60 self._sortModel.setSourceModel(self._model) 61 self.setModel(self._sortModel) 62 63 self.selectedItemsFilter = [ProjectBrowserFileItem] 64 65 # contains codes for special menu entries 66 # 1 = specials for Others browser 67 self.specialMenuEntries = [] 68 self.isTranslationsBrowser = False 69 self.expandedNames = [] 70 71 self.SelectFlags = QItemSelectionModel.SelectionFlags( 72 QItemSelectionModel.SelectionFlag.Select | 73 QItemSelectionModel.SelectionFlag.Rows 74 ) 75 self.DeselectFlags = QItemSelectionModel.SelectionFlags( 76 QItemSelectionModel.SelectionFlag.Deselect | 77 QItemSelectionModel.SelectionFlag.Rows 78 ) 79 80 self._activating = False 81 82 self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) 83 self.customContextMenuRequested.connect(self._contextMenuRequested) 84 self.activated.connect(self._openItem) 85 self._model.rowsInserted.connect(self.__modelRowsInserted) 86 self._connectExpandedCollapsed() 87 88 self._createPopupMenus() 89 90 self.currentItemName = None 91 92 self._init() # perform common initialization tasks 93 94 self._keyboardSearchString = "" 95 self._keyboardSearchTimer = QElapsedTimer() 96 self._keyboardSearchTimer.invalidate() 97 98 self._initHookMethods() # perform initialization of the hooks 99 self.hooksMenuEntries = {} 100 101 def _connectExpandedCollapsed(self): 102 """ 103 Protected method to connect the expanded and collapsed signals. 104 """ 105 self.expanded.connect(self._resizeColumns) 106 self.collapsed.connect(self._resizeColumns) 107 108 def _disconnectExpandedCollapsed(self): 109 """ 110 Protected method to disconnect the expanded and collapsed signals. 111 """ 112 self.expanded.disconnect(self._resizeColumns) 113 self.collapsed.disconnect(self._resizeColumns) 114 115 def _createPopupMenus(self): 116 """ 117 Protected overloaded method to generate the popup menus. 118 """ 119 # create the popup menu for source files 120 self.sourceMenu = QMenu(self) 121 self.sourceMenu.addAction( 122 QCoreApplication.translate('ProjectBaseBrowser', 'Open'), 123 self._openItem) 124 125 # create the popup menu for general use 126 self.menu = QMenu(self) 127 self.menu.addAction( 128 QCoreApplication.translate('ProjectBaseBrowser', 'Open'), 129 self._openItem) 130 131 # create the menu for multiple selected files 132 self.multiMenu = QMenu(self) 133 self.multiMenu.addAction( 134 QCoreApplication.translate('ProjectBaseBrowser', 'Open'), 135 self._openItem) 136 137 # create the background menu 138 self.backMenu = None 139 140 # create the directories menu 141 self.dirMenu = None 142 143 # create the directory for multiple selected directories 144 self.dirMultiMenu = None 145 146 self.menuActions = [] 147 self.multiMenuActions = [] 148 self.dirMenuActions = [] 149 self.dirMultiMenuActions = [] 150 151 self.mainMenu = None 152 153 def _contextMenuRequested(self, coord): 154 """ 155 Protected slot to show the context menu. 156 157 @param coord the position of the mouse pointer (QPoint) 158 """ 159 if not self.project.isOpen(): 160 return 161 162 cnt = self.getSelectedItemsCount() 163 if cnt > 1: 164 self.multiMenu.popup(self.mapToGlobal(coord)) 165 else: 166 index = self.indexAt(coord) 167 168 if index.isValid(): 169 self.menu.popup(self.mapToGlobal(coord)) 170 else: 171 self.backMenu and self.backMenu.popup(self.mapToGlobal(coord)) 172 173 def _selectSingleItem(self, index): 174 """ 175 Protected method to select a single item. 176 177 @param index index of item to be selected (QModelIndex) 178 """ 179 if index.isValid(): 180 self.setCurrentIndex(index) 181 flags = QItemSelectionModel.SelectionFlags( 182 QItemSelectionModel.SelectionFlag.ClearAndSelect | 183 QItemSelectionModel.SelectionFlag.Rows 184 ) 185 self.selectionModel().select(index, flags) 186 187 def _setItemSelected(self, index, selected): 188 """ 189 Protected method to set the selection status of an item. 190 191 @param index index of item to set (QModelIndex) 192 @param selected flag giving the new selection status (boolean) 193 """ 194 if index.isValid(): 195 self.selectionModel().select( 196 index, selected and self.SelectFlags or self.DeselectFlags) 197 198 def _setItemRangeSelected(self, startIndex, endIndex, selected): 199 """ 200 Protected method to set the selection status of a range of items. 201 202 @param startIndex start index of range of items to set (QModelIndex) 203 @param endIndex end index of range of items to set (QModelIndex) 204 @param selected flag giving the new selection status (boolean) 205 """ 206 selection = QItemSelection(startIndex, endIndex) 207 self.selectionModel().select( 208 selection, selected and self.SelectFlags or self.DeselectFlags) 209 210 def __modelRowsInserted(self, parent, start, end): 211 """ 212 Private slot called after rows have been inserted into the model. 213 214 @param parent parent index of inserted rows (QModelIndex) 215 @param start start row number (integer) 216 @param end end row number (integer) 217 """ 218 self._resizeColumns(parent) 219 220 def _projectClosed(self): 221 """ 222 Protected slot to handle the projectClosed signal. 223 """ 224 self.layoutDisplay() 225 if self.backMenu is not None: 226 self.backMenu.setEnabled(False) 227 228 self._createPopupMenus() 229 230 def _projectOpened(self): 231 """ 232 Protected slot to handle the projectOpened signal. 233 """ 234 self.layoutDisplay() 235 self.sortByColumn(0, Qt.SortOrder.DescendingOrder) 236 self.sortByColumn(0, Qt.SortOrder.AscendingOrder) 237 self._initMenusAndVcs() 238 239 def _initMenusAndVcs(self): 240 """ 241 Protected slot to initialize the menus and the Vcs interface. 242 """ 243 self._createPopupMenus() 244 245 if self.backMenu is not None: 246 self.backMenu.setEnabled(True) 247 248 if self.project.vcs is not None: 249 self.vcsHelper = self.project.vcs.vcsGetProjectBrowserHelper( 250 self, self.project, self.isTranslationsBrowser) 251 self.vcsHelper.addVCSMenus( 252 self.mainMenu, self.multiMenu, self.backMenu, 253 self.dirMenu, self.dirMultiMenu) 254 255 def _newProject(self): 256 """ 257 Protected slot to handle the newProject signal. 258 """ 259 # default to perform same actions as opening a project 260 self._projectOpened() 261 262 def _removeFile(self): 263 """ 264 Protected method to remove a file or files from the project. 265 """ 266 itmList = self.getSelectedItems() 267 268 for itm in itmList[:]: 269 fn = itm.fileName() 270 self.closeSourceWindow.emit(fn) 271 self.project.removeFile(fn) 272 273 def _removeDir(self): 274 """ 275 Protected method to remove a (single) directory from the project. 276 """ 277 itmList = self.getSelectedItems( 278 [ProjectBrowserSimpleDirectoryItem, ProjectBrowserDirectoryItem]) 279 for itm in itmList[:]: 280 dn = itm.dirName() 281 self.project.removeDirectory(dn) 282 283 def _deleteDirectory(self): 284 """ 285 Protected method to delete the selected directory from the project 286 data area. 287 """ 288 itmList = self.getSelectedItems() 289 290 dirs = [] 291 fullNames = [] 292 for itm in itmList: 293 dn = itm.dirName() 294 fullNames.append(dn) 295 dn = self.project.getRelativePath(dn) 296 dirs.append(dn) 297 298 from UI.DeleteFilesConfirmationDialog import ( 299 DeleteFilesConfirmationDialog 300 ) 301 dlg = DeleteFilesConfirmationDialog( 302 self.parent(), 303 QCoreApplication.translate( 304 "ProjectBaseBrowser", "Delete directories"), 305 QCoreApplication.translate( 306 "ProjectBaseBrowser", 307 "Do you really want to delete these directories from" 308 " the project?"), 309 dirs) 310 311 if dlg.exec() == QDialog.DialogCode.Accepted: 312 for dn in fullNames: 313 self.project.deleteDirectory(dn) 314 315 def _renameFile(self): 316 """ 317 Protected method to rename a file of the project. 318 """ 319 itm = self.model().item(self.currentIndex()) 320 fn = itm.fileName() 321 self.project.renameFile(fn) 322 323 def _copyToClipboard(self): 324 """ 325 Protected method to copy the path of an entry to the clipboard. 326 """ 327 itm = self.model().item(self.currentIndex()) 328 try: 329 fn = itm.fileName() 330 except AttributeError: 331 try: 332 fn = itm.dirName() 333 except AttributeError: 334 fn = "" 335 336 cb = QApplication.clipboard() 337 cb.setText(fn) 338 339 def selectFile(self, fn): 340 """ 341 Public method to highlight a node given its filename. 342 343 @param fn filename of file to be highlighted (string) 344 """ 345 newfn = os.path.abspath(fn) 346 newfn = self.project.getRelativePath(newfn) 347 sindex = self._model.itemIndexByName(newfn) 348 if sindex.isValid(): 349 index = self.model().mapFromSource(sindex) 350 if index.isValid(): 351 self._selectSingleItem(index) 352 self.scrollTo(index, 353 QAbstractItemView.ScrollHint.PositionAtTop) 354 355 def selectFileLine(self, fn, lineno): 356 """ 357 Public method to highlight a node given its filename. 358 359 @param fn filename of file to be highlighted (string) 360 @param lineno one based line number of the item (integer) 361 """ 362 newfn = os.path.abspath(fn) 363 newfn = self.project.getRelativePath(newfn) 364 sindex = self._model.itemIndexByNameAndLine(newfn, lineno) 365 if sindex.isValid(): 366 index = self.model().mapFromSource(sindex) 367 if index.isValid(): 368 self._selectSingleItem(index) 369 self.scrollTo(index) 370 371 def _expandAllDirs(self): 372 """ 373 Protected slot to handle the 'Expand all directories' menu action. 374 """ 375 self._disconnectExpandedCollapsed() 376 with E5OverrideCursor(): 377 index = self.model().index(0, 0) 378 while index.isValid(): 379 itm = self.model().item(index) 380 if ( 381 isinstance( 382 itm, 383 (ProjectBrowserSimpleDirectoryItem, 384 ProjectBrowserDirectoryItem)) and 385 not self.isExpanded(index) 386 ): 387 self.expand(index) 388 index = self.indexBelow(index) 389 self.layoutDisplay() 390 self._connectExpandedCollapsed() 391 392 def _collapseAllDirs(self): 393 """ 394 Protected slot to handle the 'Collapse all directories' menu action. 395 """ 396 self._disconnectExpandedCollapsed() 397 with E5OverrideCursor(): 398 # step 1: find last valid index 399 vindex = QModelIndex() 400 index = self.model().index(0, 0) 401 while index.isValid(): 402 vindex = index 403 index = self.indexBelow(index) 404 405 # step 2: go up collapsing all directory items 406 index = vindex 407 while index.isValid(): 408 itm = self.model().item(index) 409 if ( 410 isinstance( 411 itm, 412 (ProjectBrowserSimpleDirectoryItem, 413 ProjectBrowserDirectoryItem)) and 414 self.isExpanded(index) 415 ): 416 self.collapse(index) 417 index = self.indexAbove(index) 418 self.layoutDisplay() 419 self._connectExpandedCollapsed() 420 421 def _showContextMenu(self, menu): 422 """ 423 Protected slot called before the context menu is shown. 424 425 It enables/disables the VCS menu entries depending on the overall 426 VCS status and the file status. 427 428 @param menu reference to the menu to be shown (QMenu) 429 """ 430 if self.project.vcs is None: 431 for act in self.menuActions: 432 act.setEnabled(True) 433 else: 434 self.vcsHelper.showContextMenu(menu, self.menuActions) 435 436 def _showContextMenuMulti(self, menu): 437 """ 438 Protected slot called before the context menu (multiple selections) is 439 shown. 440 441 It enables/disables the VCS menu entries depending on the overall 442 VCS status and the files status. 443 444 @param menu reference to the menu to be shown (QMenu) 445 """ 446 if self.project.vcs is None: 447 for act in self.multiMenuActions: 448 act.setEnabled(True) 449 else: 450 self.vcsHelper.showContextMenuMulti(menu, self.multiMenuActions) 451 452 def _showContextMenuDir(self, menu): 453 """ 454 Protected slot called before the context menu is shown. 455 456 It enables/disables the VCS menu entries depending on the overall 457 VCS status and the directory status. 458 459 @param menu reference to the menu to be shown (QMenu) 460 """ 461 if self.project.vcs is None: 462 for act in self.dirMenuActions: 463 act.setEnabled(True) 464 else: 465 self.vcsHelper.showContextMenuDir(menu, self.dirMenuActions) 466 467 def _showContextMenuDirMulti(self, menu): 468 """ 469 Protected slot called before the context menu is shown. 470 471 It enables/disables the VCS menu entries depending on the overall 472 VCS status and the directory status. 473 474 @param menu reference to the menu to be shown (QMenu) 475 """ 476 if self.project.vcs is None: 477 for act in self.dirMultiMenuActions: 478 act.setEnabled(True) 479 else: 480 self.vcsHelper.showContextMenuDirMulti( 481 menu, self.dirMultiMenuActions) 482 483 def _showContextMenuBack(self, menu): 484 """ 485 Protected slot called before the context menu is shown. 486 487 @param menu reference to the menu to be shown (QMenu) 488 """ 489 # nothing to do for now 490 return 491 492 def _selectEntries(self, local=True, filterList=None): 493 """ 494 Protected method to select entries based on their VCS status. 495 496 @param local flag indicating local (i.e. non VCS controlled) 497 file/directory entries should be selected (boolean) 498 @param filterList list of classes to check against 499 """ 500 if self.project.vcs is None: 501 return 502 503 compareString = ( 504 QCoreApplication.translate('ProjectBaseBrowser', "local") 505 if local else 506 self.project.vcs.vcsName() 507 ) 508 509 # expand all directories in order to iterate over all entries 510 self._expandAllDirs() 511 512 self.selectionModel().clear() 513 514 with E5OverrideCursor(): 515 # now iterate over all entries 516 startIndex = None 517 endIndex = None 518 selectedEntries = 0 519 index = self.model().index(0, 0) 520 while index.isValid(): 521 itm = self.model().item(index) 522 if ( 523 self.wantedItem(itm, filterList) and 524 compareString == itm.data(1) 525 ): 526 if ( 527 startIndex is not None and 528 startIndex.parent() != index.parent() 529 ): 530 self._setItemRangeSelected(startIndex, endIndex, True) 531 startIndex = None 532 selectedEntries += 1 533 if startIndex is None: 534 startIndex = index 535 endIndex = index 536 else: 537 if startIndex is not None: 538 self._setItemRangeSelected(startIndex, endIndex, True) 539 startIndex = None 540 index = self.indexBelow(index) 541 if startIndex is not None: 542 self._setItemRangeSelected(startIndex, endIndex, True) 543 544 if selectedEntries == 0: 545 E5MessageBox.information( 546 self, 547 QCoreApplication.translate( 548 'ProjectBaseBrowser', "Select entries"), 549 QCoreApplication.translate( 550 'ProjectBaseBrowser', 551 """There were no matching entries found.""")) 552 553 def selectLocalEntries(self): 554 """ 555 Public slot to handle the select local files context menu entries. 556 """ 557 self._selectEntries(local=True, filterList=[ProjectBrowserFileItem]) 558 559 def selectVCSEntries(self): 560 """ 561 Public slot to handle the select VCS files context menu entries. 562 """ 563 self._selectEntries(local=False, filterList=[ProjectBrowserFileItem]) 564 565 def selectLocalDirEntries(self): 566 """ 567 Public slot to handle the select local directories context menu 568 entries. 569 """ 570 self._selectEntries( 571 local=True, 572 filterList=[ProjectBrowserSimpleDirectoryItem, 573 ProjectBrowserDirectoryItem]) 574 575 def selectVCSDirEntries(self): 576 """ 577 Public slot to handle the select VCS directories context menu entries. 578 """ 579 self._selectEntries( 580 local=False, 581 filterList=[ProjectBrowserSimpleDirectoryItem, 582 ProjectBrowserDirectoryItem]) 583 584 def getExpandedItemNames(self): 585 """ 586 Public method to get the file/directory names of all expanded items. 587 588 @return list of expanded items names (list of string) 589 """ 590 expandedNames = [] 591 592 childIndex = self.model().index(0, 0) 593 while childIndex.isValid(): 594 if self.isExpanded(childIndex): 595 with contextlib.suppress(AttributeError): 596 expandedNames.append( 597 self.model().item(childIndex).name()) 598 # only items defining the name() method are returned 599 childIndex = self.indexBelow(childIndex) 600 601 return expandedNames 602 603 def expandItemsByName(self, names): 604 """ 605 Public method to expand items given their names. 606 607 @param names list of item names to be expanded (list of string) 608 """ 609 model = self.model() 610 for name in names: 611 childIndex = model.index(0, 0) 612 while childIndex.isValid(): 613 with contextlib.suppress(AttributeError): 614 if model.item(childIndex).name() == name: 615 self.setExpanded(childIndex, True) 616 break 617 # ignore items not supporting this method 618 childIndex = self.indexBelow(childIndex) 619 620 def _prepareRepopulateItem(self, name): 621 """ 622 Protected slot to handle the prepareRepopulateItem signal. 623 624 @param name relative name of file item to be repopulated (string) 625 """ 626 itm = self.currentItem() 627 if itm is not None: 628 self.currentItemName = itm.data(0) 629 self.expandedNames = [] 630 sindex = self._model.itemIndexByName(name) 631 if not sindex.isValid(): 632 return 633 634 index = self.model().mapFromSource(sindex) 635 if not index.isValid(): 636 return 637 638 childIndex = self.indexBelow(index) 639 while childIndex.isValid(): 640 if childIndex.parent() == index.parent(): 641 break 642 if self.isExpanded(childIndex): 643 self.expandedNames.append( 644 self.model().item(childIndex).data(0)) 645 childIndex = self.indexBelow(childIndex) 646 647 def _completeRepopulateItem(self, name): 648 """ 649 Protected slot to handle the completeRepopulateItem signal. 650 651 @param name relative name of file item to be repopulated (string) 652 """ 653 sindex = self._model.itemIndexByName(name) 654 if sindex.isValid(): 655 index = self.model().mapFromSource(sindex) 656 if index.isValid(): 657 if self.isExpanded(index): 658 childIndex = self.indexBelow(index) 659 while childIndex.isValid(): 660 if ( 661 not childIndex.isValid() or 662 childIndex.parent() == index.parent() 663 ): 664 break 665 itm = self.model().item(childIndex) 666 if itm is not None: 667 itemData = itm.data(0) 668 if ( 669 self.currentItemName and 670 self.currentItemName == itemData 671 ): 672 self._selectSingleItem(childIndex) 673 if itemData in self.expandedNames: 674 self.setExpanded(childIndex, True) 675 childIndex = self.indexBelow(childIndex) 676 else: 677 self._selectSingleItem(index) 678 self.expandedNames = [] 679 self.currentItemName = None 680 self._resort() 681 682 def currentItem(self): 683 """ 684 Public method to get a reference to the current item. 685 686 @return reference to the current item 687 """ 688 itm = self.model().item(self.currentIndex()) 689 return itm 690 691 def _keyboardSearchType(self, item): 692 """ 693 Protected method to check, if the item is of the correct type. 694 695 @param item reference to the item 696 @type BrowserItem 697 @return flag indicating a correct type 698 @rtype bool 699 """ 700 return isinstance( 701 item, (BrowserDirectoryItem, BrowserFileItem, 702 ProjectBrowserSimpleDirectoryItem, 703 ProjectBrowserDirectoryItem, ProjectBrowserFileItem)) 704 705 ########################################################################### 706 ## Support for hooks below 707 ########################################################################### 708 709 def _initHookMethods(self): 710 """ 711 Protected method to initialize the hooks dictionary. 712 713 This method should be overridden by subclasses. All supported 714 hook methods should be initialized with a None value. The keys 715 must be strings. 716 """ 717 self.hooks = {} 718 719 def __checkHookKey(self, key): 720 """ 721 Private method to check a hook key. 722 723 @param key key of the hook to check (string) 724 @exception KeyError raised to indicate an invalid hook 725 """ 726 if len(self.hooks) == 0: 727 raise KeyError("Hooks are not initialized.") 728 729 if key not in self.hooks: 730 raise KeyError(key) 731 732 def addHookMethod(self, key, method): 733 """ 734 Public method to add a hook method to the dictionary. 735 736 @param key for the hook method (string) 737 @param method reference to the hook method (method object) 738 """ 739 self.__checkHookKey(key) 740 self.hooks[key] = method 741 742 def addHookMethodAndMenuEntry(self, key, method, menuEntry): 743 """ 744 Public method to add a hook method to the dictionary. 745 746 @param key for the hook method (string) 747 @param method reference to the hook method (method object) 748 @param menuEntry entry to be shown in the context menu (string) 749 """ 750 self.addHookMethod(key, method) 751 self.hooksMenuEntries[key] = menuEntry 752 753 def removeHookMethod(self, key): 754 """ 755 Public method to remove a hook method from the dictionary. 756 757 @param key for the hook method (string) 758 """ 759 self.__checkHookKey(key) 760 self.hooks[key] = None 761 if key in self.hooksMenuEntries: 762 del self.hooksMenuEntries[key] 763 764 ################################################################## 765 ## Configure method below 766 ################################################################## 767 768 def _configure(self): 769 """ 770 Protected method to open the configuration dialog. 771 """ 772 e5App().getObject("UserInterface").showPreferences( 773 "projectBrowserPage") 774