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