1# -*- coding: utf-8 -*- 2 3# Copyright (c) 2020 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> 4# 5 6""" 7Module implementing an outline widget for source code navigation of the editor. 8""" 9 10import contextlib 11 12from PyQt5.QtCore import pyqtSlot, Qt, QCoreApplication, QModelIndex, QPoint 13from PyQt5.QtWidgets import QTreeView, QAbstractItemView, QMenu, QApplication 14 15from UI.BrowserSortFilterProxyModel import BrowserSortFilterProxyModel 16from UI.BrowserModel import ( 17 BrowserImportsItem, BrowserGlobalsItem, BrowserClassAttributeItem, 18 BrowserImportItem 19) 20 21from .EditorOutlineModel import EditorOutlineModel 22 23import Preferences 24 25 26class EditorOutlineView(QTreeView): 27 """ 28 Class implementing an outline widget for source code navigation of the 29 editor. 30 """ 31 def __init__(self, editor, populate=True, parent=None): 32 """ 33 Constructor 34 35 @param editor reference to the editor widget 36 @type Editor 37 @param populate flag indicating to populate the outline 38 @type bool 39 @param parent reference to the parent widget 40 @type QWidget 41 """ 42 super().__init__(parent) 43 44 self.__model = EditorOutlineModel(editor, populate=populate) 45 self.__sortModel = BrowserSortFilterProxyModel() 46 self.__sortModel.setSourceModel(self.__model) 47 self.setModel(self.__sortModel) 48 49 self.setRootIsDecorated(True) 50 self.setAlternatingRowColors(True) 51 52 header = self.header() 53 header.setSortIndicator(0, Qt.SortOrder.AscendingOrder) 54 header.setSortIndicatorShown(True) 55 header.setSectionsClickable(True) 56 self.setHeaderHidden(True) 57 58 self.setSortingEnabled(True) 59 60 self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) 61 self.setSelectionBehavior( 62 QAbstractItemView.SelectionBehavior.SelectRows) 63 64 self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) 65 self.customContextMenuRequested.connect(self.__contextMenuRequested) 66 self.__createPopupMenus() 67 68 self.activated.connect(self.__gotoItem) 69 self.expanded.connect(self.__resizeColumns) 70 self.collapsed.connect(self.__resizeColumns) 71 72 self.__resizeColumns() 73 74 self.__expandedNames = [] 75 self.__currentItemName = "" 76 self.__signalsConnected = False 77 78 def setActive(self, active): 79 """ 80 Public method to activate or deactivate the outline view. 81 82 @param active flag indicating the requested action 83 @type bool 84 """ 85 if active and not self.__signalsConnected: 86 editor = self.__model.editor() 87 editor.refreshed.connect(self.repopulate) 88 editor.languageChanged.connect(self.__editorLanguageChanged) 89 editor.editorRenamed.connect(self.__editorRenamed) 90 editor.cursorLineChanged.connect(self.__editorCursorLineChanged) 91 92 self.__model.repopulate() 93 self.__resizeColumns() 94 95 line, _ = editor.getCursorPosition() 96 self.__editorCursorLineChanged(line) 97 98 elif not active and self.__signalsConnected: 99 editor = self.__model.editor() 100 editor.refreshed.disconnect(self.repopulate) 101 editor.languageChanged.disconnect(self.__editorLanguageChanged) 102 editor.editorRenamed.disconnect(self.__editorRenamed) 103 editor.cursorLineChanged.disconnect(self.__editorCursorLineChanged) 104 105 self.__model.clear() 106 107 @pyqtSlot() 108 def __resizeColumns(self): 109 """ 110 Private slot to resize the view when items get expanded or collapsed. 111 """ 112 self.resizeColumnToContents(0) 113 114 def isPopulated(self): 115 """ 116 Public method to check, if the model is populated. 117 118 @return flag indicating a populated model 119 @rtype bool 120 """ 121 return self.__model.isPopulated() 122 123 @pyqtSlot() 124 def repopulate(self): 125 """ 126 Public slot to repopulate the model. 127 """ 128 if self.isPopulated(): 129 self.__prepareRepopulate() 130 self.__model.repopulate() 131 self.__completeRepopulate() 132 133 @pyqtSlot() 134 def __prepareRepopulate(self): 135 """ 136 Private slot to prepare to repopulate the outline view. 137 """ 138 itm = self.__currentItem() 139 if itm is not None: 140 self.__currentItemName = itm.data(0) 141 142 self.__expandedNames = [] 143 144 childIndex = self.model().index(0, 0) 145 while childIndex.isValid(): 146 if self.isExpanded(childIndex): 147 self.__expandedNames.append( 148 self.model().item(childIndex).data(0)) 149 childIndex = self.indexBelow(childIndex) 150 151 @pyqtSlot() 152 def __completeRepopulate(self): 153 """ 154 Private slot to complete the repopulate of the outline view. 155 """ 156 childIndex = self.model().index(0, 0) 157 while childIndex.isValid(): 158 name = self.model().item(childIndex).data(0) 159 if (self.__currentItemName and self.__currentItemName == name): 160 self.setCurrentIndex(childIndex) 161 if name in self.__expandedNames: 162 self.setExpanded(childIndex, True) 163 childIndex = self.indexBelow(childIndex) 164 self.__resizeColumns() 165 166 self.__expandedNames = [] 167 self.__currentItemName = "" 168 169 def isSupportedLanguage(self, language): 170 """ 171 Public method to check, if outlining a given language is supported. 172 173 @param language source language to be checked 174 @type str 175 @return flag indicating support 176 @rtype bool 177 """ 178 return language in EditorOutlineModel.SupportedLanguages 179 180 @pyqtSlot(QModelIndex) 181 def __gotoItem(self, index): 182 """ 183 Private slot to set the editor cursor. 184 185 @param index index of the item to set the cursor for 186 @type QModelIndex 187 """ 188 if index.isValid(): 189 itm = self.model().item(index) 190 if itm: 191 with contextlib.suppress(AttributeError): 192 lineno = itm.lineno() 193 self.__model.editor().gotoLine(lineno) 194 195 def mouseDoubleClickEvent(self, mouseEvent): 196 """ 197 Protected method of QAbstractItemView. 198 199 Reimplemented to disable expanding/collapsing of items when 200 double-clicking. Instead the double-clicked entry is opened. 201 202 @param mouseEvent the mouse event (QMouseEvent) 203 """ 204 index = self.indexAt(mouseEvent.pos()) 205 if index.isValid(): 206 itm = self.model().item(index) 207 if isinstance(itm, (BrowserImportsItem, BrowserGlobalsItem)): 208 self.setExpanded(index, not self.isExpanded(index)) 209 else: 210 self.__gotoItem(index) 211 212 def __currentItem(self): 213 """ 214 Private method to get a reference to the current item. 215 216 @return reference to the current item 217 @rtype BrowserItem 218 """ 219 itm = self.model().item(self.currentIndex()) 220 return itm 221 222 ####################################################################### 223 ## Context menu methods below 224 ####################################################################### 225 226 def __createPopupMenus(self): 227 """ 228 Private method to generate the various popup menus. 229 """ 230 # create the popup menu for general use 231 self.__menu = QMenu(self) 232 self.__menu.addAction( 233 QCoreApplication.translate('EditorOutlineView', 'Goto'), 234 self.__goto) 235 self.__menu.addSeparator() 236 self.__menu.addAction( 237 QCoreApplication.translate('EditorOutlineView', 'Refresh'), 238 self.repopulate) 239 self.__menu.addSeparator() 240 self.__menu.addAction( 241 QCoreApplication.translate( 242 'EditorOutlineView', 'Copy Path to Clipboard'), 243 self.__copyToClipboard) 244 self.__menu.addSeparator() 245 self.__menu.addAction( 246 QCoreApplication.translate( 247 'EditorOutlineView', 'Expand All'), 248 lambda: self.expandToDepth(-1)) 249 self.__menu.addAction( 250 QCoreApplication.translate( 251 'EditorOutlineView', 'Collapse All'), 252 self.collapseAll) 253 self.__menu.addSeparator() 254 self.__menu.addAction( 255 QCoreApplication.translate( 256 'EditorOutlineView', 'Increment Width'), 257 self.__incWidth) 258 self.__decWidthAct = self.__menu.addAction( 259 QCoreApplication.translate( 260 'EditorOutlineView', 'Decrement Width'), 261 self.__decWidth) 262 self.__menu.addAction( 263 QCoreApplication.translate( 264 'EditorOutlineView', 'Set Default Width'), 265 self.__defaultWidth) 266 267 # create the attribute/import menu 268 self.__gotoMenu = QMenu( 269 QCoreApplication.translate('EditorOutlineView', "Goto"), 270 self) 271 self.__gotoMenu.aboutToShow.connect(self.__showGotoMenu) 272 self.__gotoMenu.triggered.connect(self.__gotoAttribute) 273 274 self.__attributeMenu = QMenu(self) 275 self.__attributeMenu.addMenu(self.__gotoMenu) 276 self.__attributeMenu.addSeparator() 277 self.__attributeMenu.addAction( 278 QCoreApplication.translate('EditorOutlineView', 'Refresh'), 279 self.repopulate) 280 self.__attributeMenu.addSeparator() 281 self.__attributeMenu.addAction( 282 QCoreApplication.translate( 283 'EditorOutlineView', 'Copy Path to Clipboard'), 284 self.__copyToClipboard) 285 self.__attributeMenu.addSeparator() 286 self.__attributeMenu.addAction( 287 QCoreApplication.translate( 288 'EditorOutlineView', 'Expand All'), 289 lambda: self.expandToDepth(-1)) 290 self.__attributeMenu.addAction( 291 QCoreApplication.translate( 292 'EditorOutlineView', 'Collapse All'), 293 self.collapseAll) 294 self.__attributeMenu.addSeparator() 295 self.__attributeMenu.addAction( 296 QCoreApplication.translate( 297 'EditorOutlineView', 'Increment Width'), 298 self.__incWidth) 299 self.__attributeDecWidthAct = self.__attributeMenu.addAction( 300 QCoreApplication.translate( 301 'EditorOutlineView', 'Decrement Width'), 302 self.__decWidth) 303 self.__attributeMenu.addAction( 304 QCoreApplication.translate( 305 'EditorOutlineView', 'Set Default Width'), 306 self.__defaultWidth) 307 308 # create the background menu 309 self.__backMenu = QMenu(self) 310 self.__backMenu.addAction( 311 QCoreApplication.translate('EditorOutlineView', 'Refresh'), 312 self.repopulate) 313 self.__backMenu.addSeparator() 314 self.__backMenu.addAction( 315 QCoreApplication.translate( 316 'EditorOutlineView', 'Copy Path to Clipboard'), 317 self.__copyToClipboard) 318 self.__backMenu.addSeparator() 319 self.__backMenu.addAction( 320 QCoreApplication.translate( 321 'EditorOutlineView', 'Expand All'), 322 lambda: self.expandToDepth(-1)) 323 self.__backMenu.addAction( 324 QCoreApplication.translate( 325 'EditorOutlineView', 'Collapse All'), 326 self.collapseAll) 327 self.__backMenu.addSeparator() 328 self.__backMenu.addAction( 329 QCoreApplication.translate( 330 'EditorOutlineView', 'Increment Width'), 331 self.__incWidth) 332 self.__backDecWidthAct = self.__backMenu.addAction( 333 QCoreApplication.translate( 334 'EditorOutlineView', 'Decrement Width'), 335 self.__decWidth) 336 self.__backMenu.addAction( 337 QCoreApplication.translate( 338 'EditorOutlineView', 'Set Default Width'), 339 self.__defaultWidth) 340 341 @pyqtSlot(QPoint) 342 def __contextMenuRequested(self, coord): 343 """ 344 Private slot to show the context menu. 345 346 @param coord position of the mouse pointer 347 @type QPoint 348 """ 349 index = self.indexAt(coord) 350 coord = self.mapToGlobal(coord) 351 352 decWidthEnable = ( 353 self.maximumWidth() != 354 2 * Preferences.getEditor("SourceOutlineStepSize") 355 ) 356 357 if index.isValid(): 358 self.setCurrentIndex(index) 359 360 itm = self.model().item(index) 361 if isinstance( 362 itm, (BrowserClassAttributeItem, BrowserImportItem) 363 ): 364 self.__attributeDecWidthAct.setEnabled(decWidthEnable) 365 self.__attributeMenu.popup(coord) 366 else: 367 self.__decWidthAct.setEnabled(decWidthEnable) 368 self.__menu.popup(coord) 369 else: 370 self.__backDecWidthAct.setEnabled(decWidthEnable) 371 self.__backMenu.popup(coord) 372 373 @pyqtSlot() 374 def __showGotoMenu(self): 375 """ 376 Private slot to prepare the goto submenu of the attribute menu. 377 """ 378 self.__gotoMenu.clear() 379 380 itm = self.model().item(self.currentIndex()) 381 try: 382 linenos = itm.linenos() 383 except AttributeError: 384 try: 385 linenos = [itm.lineno()] 386 except AttributeError: 387 return 388 389 for lineno in sorted(linenos): 390 act = self.__gotoMenu.addAction( 391 QCoreApplication.translate( 392 'EditorOutlineView', "Line {0}").format(lineno)) 393 act.setData(lineno) 394 395 ####################################################################### 396 ## Context menu handlers below 397 ####################################################################### 398 399 @pyqtSlot() 400 def __gotoAttribute(self, act): 401 """ 402 Private slot to handle the selection of the goto menu. 403 404 @param act reference to the action (E5Action) 405 """ 406 lineno = act.data() 407 self.__model.editor().gotoLine(lineno) 408 409 @pyqtSlot() 410 def __goto(self): 411 """ 412 Private slot to move the editor cursor to the line of the context item. 413 """ 414 self.__gotoItem(self.currentIndex()) 415 416 @pyqtSlot() 417 def __copyToClipboard(self): 418 """ 419 Private slot to copy the file name of the editor to the clipboard. 420 """ 421 fn = self.__model.fileName() 422 423 if fn: 424 cb = QApplication.clipboard() 425 cb.setText(fn) 426 427 @pyqtSlot() 428 def __incWidth(self): 429 """ 430 Private slot to increment the width of the outline. 431 """ 432 self.setMaximumWidth( 433 self.maximumWidth() + 434 Preferences.getEditor("SourceOutlineStepSize") 435 ) 436 self.updateGeometry() 437 438 @pyqtSlot() 439 def __decWidth(self): 440 """ 441 Private slot to decrement the width of the outline. 442 """ 443 stepSize = Preferences.getEditor("SourceOutlineStepSize") 444 newWidth = self.maximumWidth() - stepSize 445 446 self.setMaximumWidth(max(newWidth, 2 * stepSize)) 447 self.updateGeometry() 448 449 @pyqtSlot() 450 def __defaultWidth(self): 451 """ 452 Private slot to set the outline to the default width. 453 """ 454 self.setMaximumWidth(Preferences.getEditor("SourceOutlineWidth")) 455 self.updateGeometry() 456 457 ####################################################################### 458 ## Methods handling editor signals below 459 ####################################################################### 460 461 @pyqtSlot() 462 def __editorLanguageChanged(self): 463 """ 464 Private slot handling a change of the associated editors source code 465 language. 466 """ 467 self.__model.repopulate() 468 self.__resizeColumns() 469 470 @pyqtSlot() 471 def __editorRenamed(self): 472 """ 473 Private slot handling a renaming of the associated editor. 474 """ 475 self.__model.repopulate() 476 self.__resizeColumns() 477 478 @pyqtSlot(int) 479 def __editorCursorLineChanged(self, lineno): 480 """ 481 Private method to highlight a node given its line number. 482 483 @param lineno zero based line number of the item 484 @type int 485 """ 486 sindex = self.__model.itemIndexByLine(lineno + 1) 487 if sindex.isValid(): 488 index = self.model().mapFromSource(sindex) 489 if index.isValid(): 490 self.setCurrentIndex(index) 491 self.scrollTo(index) 492 else: 493 self.setCurrentIndex(QModelIndex()) 494