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