Ejemplo n.º 1
0
class MainWindow(QMainWindow):
    '''The main window of the application.'''
    def __init__(self, config: Config):
        '''Initializes a MainWindow instance.'''
        super().__init__()

        # Store configuration.
        self._config = config

        # Create model.
        self._model = MainModel()
        self._model.matchCase = config.matchCase
        self._model.includePrivateMembers = config.includePrivateMembers
        self._model.includeInheritedMembers = config.includeInheritedMembers
        self._model.sortByType = config.sortByType
        self._model.setModuleNames(config.moduleNames)

        # Configure window.
        self.setWindowTitle('pyspector')
        self.setGeometry(100, 100, 1200, 800)

        # Crete user interface widgets.
        self._searchEdit = SearchEdit()
        self._searchEdit.textChanged.connect(self._searchEditTextChanged)
        self._searchEdit.delayedTextChanged.connect(self._selectFirstMatch)

        matchCaseCheckBox = QCheckBox()
        matchCaseCheckBox.setText('Match case')
        matchCaseCheckBox.setCheckState(
            Qt.Checked if self._model.matchCase else Qt.Unchecked)
        matchCaseCheckBox.stateChanged.connect(
            self._matchCaseCheckBoxStateChanged)

        includePrivateCheckBox = QCheckBox()
        includePrivateCheckBox.setText('Include private members')
        includePrivateCheckBox.setCheckState(
            Qt.Checked if self._model.includePrivateMembers else Qt.Unchecked)
        includePrivateCheckBox.stateChanged.connect(
            self._includePrivateCheckBoxStateChanged)

        includeInheritedCheckBox = QCheckBox()
        includeInheritedCheckBox.setText('Include inherited members')
        includeInheritedCheckBox.setCheckState(
            Qt.Checked if self._model.includeInheritedMembers else Qt.Unchecked
        )
        includeInheritedCheckBox.stateChanged.connect(
            self._includeInheritedCheckBoxStateChanged)

        sortByTypeCheckBox = QCheckBox()
        sortByTypeCheckBox.setText('Sort by type')
        sortByTypeCheckBox.setCheckState(
            Qt.Checked if self._model.sortByType else Qt.Unchecked)
        sortByTypeCheckBox.stateChanged.connect(
            self._sortByTypeCheckBoxStateChanged)

        self._treeView = TreeView()
        self._treeView.setUniformRowHeights(True)
        self._treeView.setAlternatingRowColors(True)
        self._treeView.setHeaderHidden(True)
        self._treeView.setModel(self._model.filteredTreeModel)
        self._treeView.hideColumn(1)
        self._treeView.hideColumn(2)
        selectionModel = self._treeView.selectionModel()
        selectionModel.currentChanged.connect(self._treeViewSelectionChanged)

        selectModulesButton = QPushButton()
        selectModulesButton.setText('Select modules')
        selectModulesButton.clicked.connect(self._selectModulesButtonClicked)

        leftLayout = QVBoxLayout()
        leftLayout.addWidget(self._searchEdit)
        leftLayout.addWidget(matchCaseCheckBox)
        leftLayout.addWidget(includePrivateCheckBox)
        leftLayout.addWidget(includeInheritedCheckBox)
        leftLayout.addWidget(sortByTypeCheckBox)
        leftLayout.addWidget(self._treeView)
        leftLayout.addWidget(selectModulesButton)
        leftLayout.setContentsMargins(0, 0, 0, 0)
        leftWidget = QWidget()
        leftWidget.setLayout(leftLayout)

        self._textBrowser = QTextBrowser()
        self._textBrowser.setOpenLinks(False)
        self._textBrowser.anchorClicked.connect(self._linkClicked)

        fontFamily = 'Menlo' if platform.system() == 'Darwin' else 'Consolas'
        fixedPitchFont = QFont(fontFamily)
        fixedPitchFont.setFixedPitch(True)
        self._sourceTextViewer = QPlainTextEdit()
        self._sourceTextViewer.setReadOnly(True)
        self._sourceTextViewer.setFont(fixedPitchFont)
        self._sourceTextViewer.setLineWrapMode(QPlainTextEdit.NoWrap)
        self._sourceTextHighlighter = PythonSyntaxHighlighter(
            self._sourceTextViewer.document())

        rightSplitter = QSplitter()
        rightSplitter.setOrientation(Qt.Vertical)
        rightSplitter.setHandleWidth(20)
        rightSplitter.setChildrenCollapsible(False)
        rightSplitter.addWidget(self._textBrowser)
        rightSplitter.addWidget(self._sourceTextViewer)

        splitter = QSplitter()
        splitter.setHandleWidth(20)
        splitter.setChildrenCollapsible(False)
        splitter.addWidget(leftWidget)
        splitter.addWidget(rightSplitter)
        splitter.setSizes([300, 900])

        centralLayout = QHBoxLayout()
        centralLayout.addWidget(splitter)
        centralWidget = QWidget()
        centralWidget.setLayout(centralLayout)
        self.setCentralWidget(centralWidget)

        # Create keyboard shortcuts.
        findShortcut = QShortcut(QKeySequence.Find, centralWidget)
        findShortcut.activated.connect(self._findShortcutActivated)

        # Make sure colors are correct for current palette.
        self._updateColors()

        # Show the window.
        self.show()

    def _findShortcutActivated(self) -> None:
        self._searchEdit.selectAll()
        self._searchEdit.setFocus()

    def _searchEditTextChanged(self, text: str) -> None:
        '''Filters the tree view to show just those items relevant to the search text.'''
        self._model.searchText = text

    def _matchCaseCheckBoxStateChanged(self, state: Qt.CheckState) -> None:
        '''Determines whether matching is case-sensitive.'''
        isChecked = state == Qt.Checked
        self._config.matchCase = isChecked
        self._model.matchCase = isChecked
        self._selectFirstMatch()

    def _includePrivateCheckBoxStateChanged(self,
                                            state: Qt.CheckState) -> None:
        ''' Includes or excludes private members from the tree view.'''
        isChecked = state == Qt.Checked
        self._config.includePrivateMembers = isChecked
        self._model.includePrivateMembers = isChecked
        self._selectFirstMatch()

    def _includeInheritedCheckBoxStateChanged(self,
                                              state: Qt.CheckState) -> None:
        ''' Includes or excludes inherited members from the tree view.'''
        isChecked = state == Qt.Checked
        self._config.includeInheritedMembers = isChecked
        self._model.includeInheritedMembers = isChecked
        self._selectFirstMatch()

    def _sortByTypeCheckBoxStateChanged(self, state: Qt.CheckState) -> None:
        ''' Includes or excludes private members from the tree view.'''
        isChecked = state == Qt.Checked
        self._config.sortByType = isChecked
        self._model.sortByType = isChecked

    def _selectFirstMatch(self) -> None:
        # Select the first match to the current search text (if any).
        searchText = self._model.searchText
        index = self._model.findItemByName(searchText) if len(
            searchText) else QModelIndex()
        if index.isValid():
            self._treeView.expandAll()
            self._treeView.scrollTo(index)
            self._treeView.selectionModel().select(
                index, QItemSelectionModel.ClearAndSelect)
            self._updateInfo(index)
        else:
            self._treeView.collapseAll()
            selectedIndexes = self._treeView.selectedIndexes()
            if len(selectedIndexes):
                self._treeView.scrollTo(selectedIndexes[0])

    def changeEvent(self, event: QEvent) -> None:
        '''Updates colors when palette change events occur.'''
        if event.type() == QEvent.PaletteChange:
            self._updateColors()

    def _updateColors(self) -> None:
        '''Modifies colors when the palette changes from dark to light or vice versa.'''
        isDark = self.palette().window().color().valueF() < 0.5
        textColor = 'silver' if isDark else 'black'
        linkColor = 'steelBlue' if isDark else 'blue'
        self._textBrowser.document().setDefaultStyleSheet(
            f'* {{ color: {textColor}; }} a {{ color: {linkColor}; }}')
        self._updateInfo()
        self._sourceTextViewer.setStyleSheet(
            f'QPlainTextEdit {{ color: {textColor}; }}')
        self._sourceTextHighlighter.theme = Theme.DARK if isDark else Theme.LIGHT

    def _treeViewSelectionChanged(self, index: QModelIndex,
                                  oldIndex: QModelIndex) -> None:
        '''Displays appropriate information whenever the tree view selection changes.'''
        self._updateInfo(index)

    def _updateInfo(self, index: QModelIndex = QModelIndex()) -> None:
        '''Determines which object is selected and displays appropriate info.'''
        if not index.isValid():
            selectedIndexes = self._treeView.selectedIndexes()
            if len(selectedIndexes):
                index = selectedIndexes[0]

        item = utilities.getItemFromIndex(self._model.filteredTreeModel, index)
        if item:
            self._displayInfo(item)
        else:
            self._textBrowser.clear()

    def _displayInfo(self, item: QStandardItem) -> None:
        '''Updates the detailed view to show information about the selected object.'''
        data = item.data()
        memberType = data['type']
        memberValue = data['value']
        error = data['error']

        # Display the fully qualified name of the item.
        # TODO: Use __qualname__?
        fullName = item.text()
        tempItem = item.parent()
        while tempItem:
            fullName = tempItem.text() + '.' + fullName
            tempItem = tempItem.parent()
        html = f'<h2>{fullName}</h2>'
        if hasattr(memberValue, '__qualname__'):
            html += f'<h2>{memberValue.__qualname__}</h2>'

        # Display the type.
        displayType = memberType
        if memberType == 'object':
            displayType = str(type(memberValue))
        html += f'<p><b>Type:</b> {escape(displayType)}</p>'

        # Display object value.
        if memberType == 'object':
            html += f'<p><b>Value:</b> {escape(repr(memberValue))}'

        # Display error message.
        if len(error):
            html += f'<p><b>Error:</b> {escape(error)}'

        # Display the filename for modules.
        # See if we can find the source file for other objects.
        if memberType == 'module' and hasattr(memberValue, '__file__'):
            html += f'<p><b>File:</b> <a href="file:{memberValue.__file__}">{memberValue.__file__}</a></p>'
            self._displaySource(memberValue.__file__)
        else:
            try:
                # Substitute the getter, setter, or deleter for a property instance.
                # TODO: Generalize this to data descriptors other than just the 'property' class.
                if isinstance(memberValue, property):
                    if memberValue.fget:
                        memberValue = memberValue.fget
                    elif memberValue.fset:
                        memberValue = memberValue.fset
                    elif memberValue.fdel:
                        memberValue = memberValue.fdel

                # Note that inspect.getsourcelines calls unwrap, while getsourcefile does not.
                sourceFile = inspect.getsourcefile(inspect.unwrap(memberValue))
                lines = inspect.getsourcelines(memberValue)
                html += f'<p><b>File:</b> <a href="file:{sourceFile}">{sourceFile} ({lines[1]})</a></p>'
                self._displaySource(sourceFile, lines[1], len(lines[0]))
            except:
                self._displaySourceError('Could not locate source code.')

        # Display the inheritance hierarchy of classes.
        if 'class' in memberType:
            try:
                baseClasses = inspect.getmro(memberValue)
                if len(baseClasses) > 1:
                    html += '<p><b>Base classes:</b></p><ul>'
                    baseClasses = list(baseClasses)[1:]  # omit the first entry
                    for baseClass in reversed(baseClasses):
                        moduleName = baseClass.__module__
                        className = baseClass.__qualname__
                        html += f'<li><a href="item:{moduleName}/{className}">{className}</a> from {moduleName}</li>'
                    html += '</ul>'
                derivedClasses = memberValue.__subclasses__()
                if len(derivedClasses) > 0:
                    html += '<p><b>Derived classes:</b></p><ul>'
                    for derivedClass in derivedClasses:
                        moduleName = derivedClass.__module__
                        className = derivedClass.__qualname__
                        html += f'<li><a href="item:{moduleName}/{className}">{className}</a> from {moduleName}</li>'
                    html += '</ul>'
            except:
                pass

        # Display the signature of callable objects.
        try:
            signature = str(inspect.signature(memberValue))
            html += f'<p><b>Signature:</b> {memberValue.__name__}{signature}</p>'
        except:
            pass

        # Display documentation for non-object types, converting from reStructuredText or markdown
        # to HTML.
        if memberType != 'object':
            doc = inspect.getdoc(data['value'])
            if doc:
                # Check for special cases where docstrings are plain text.
                if fullName in ['sys']:
                    docHtml = f'<pre>{escape(doc)}</pre>'
                else:
                    # If we encounter improper reStructuredText markup leading to an exception
                    # or a "problematic" span, just treat the input as markdown.
                    try:
                        docHtml = rstToHtml(doc)
                        if '<span class="problematic"' in docHtml:
                            docHtml = markdown(doc)
                    except:
                        docHtml = markdown(doc)
                html += f'<hr>{docHtml}'

        self._textBrowser.setHtml(html)

    def _displaySource(self,
                       filename: str,
                       startLine: int = None,
                       lineCount: int = None) -> None:
        '''Shows source code within the source text viewer.'''
        try:
            # Read the file and populate the source text viewer.
            with open(filename) as fp:
                lines = fp.readlines()
                self._sourceTextViewer.setPlainText(''.join(lines))

            # Restart the Python syntax highlighter.
            self._sourceTextHighlighter.setDocument(
                self._sourceTextViewer.document())

            # If line numbers are available, highlight the lines encompassing the currently
            # selected member.
            if startLine != None:
                cursor = QTextCursor(self._sourceTextViewer.document())
                cursor.movePosition(QTextCursor.Start)
                if startLine > 1:
                    cursor.movePosition(QTextCursor.NextBlock,
                                        QTextCursor.MoveAnchor, startLine - 1)
                self._sourceTextViewer.setTextCursor(cursor)
                cursor.movePosition(QTextCursor.NextBlock,
                                    QTextCursor.KeepAnchor, lineCount)
                lineColor = QColor(255, 255, 0, 48)
                extraSelection = QTextEdit.ExtraSelection()
                extraSelection.format.setBackground(lineColor)
                extraSelection.format.setProperty(
                    QTextFormat.FullWidthSelection, True)
                extraSelection.cursor = cursor
                self._sourceTextViewer.setExtraSelections([extraSelection])
                self._sourceTextViewer.centerCursor()
            else:
                self._sourceTextViewer.setExtraSelections([])
        except:
            self._displaySourceError('Could not open file.')

    def _displaySourceError(self, errorMessage: str) -> None:
        '''Displays an error message within the source text viewer.'''
        self._sourceTextViewer.setPlainText(errorMessage)
        self._sourceTextViewer.setExtraSelections([])
        self._sourceTextHighlighter.setDocument(None)

    def _linkClicked(self, url: QUrl) -> None:
        scheme = url.scheme()
        if scheme == 'file':
            # Open the file in an external application.
            utilities.openFile(url.path())
        elif scheme == 'http' or scheme == 'https':
            # Open the web page in the default browser.
            webbrowser.open_new_tab(url.toString())
        elif scheme == 'item':
            # Clear the search and select the item (if present in the tree).
            self._searchEdit.clear()
            self._model.searchText = ''
            index = self._model.findItemById(url.path())
            if index.isValid():
                self._treeView.setCurrentIndex(index)

    def _selectModulesButtonClicked(self) -> None:
        self._moduleSelectionDialog = ModuleSelectionDialog(
            self, self._config.moduleNames)
        self._moduleSelectionDialog.finished.connect(
            self._moduleSelectionDialogFinished)
        self._moduleSelectionDialog.open()

    def _moduleSelectionDialogFinished(self, result: int) -> None:
        if result == ModuleSelectionDialog.Accepted:
            moduleNames = self._moduleSelectionDialog.selectedModuleNames
            self._model.setModuleNames(moduleNames)
            self._config.moduleNames = moduleNames
            self._selectFirstMatch()