1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2017 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing a widget to show some source code information provided by
8plug-ins.
9"""
10
11from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QUrl, QTimer
12from PyQt5.QtGui import QCursor
13from PyQt5.QtWidgets import (
14    QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QSizePolicy,
15    QLineEdit, QTextBrowser, QToolTip
16)
17
18from E5Gui.E5TextEditSearchWidget import E5TextEditSearchWidget, E5TextEditType
19from E5Gui.E5Application import e5App
20
21import Preferences
22
23from .CodeDocumentationViewerTemplate import (
24    prepareDocumentationViewerHtmlDocument,
25    prepareDocumentationViewerHtmlDocWarningDocument,
26    prepareDocumentationViewerHtmlWarningDocument
27)
28
29
30class DocumentationViewerWidget(QWidget):
31    """
32    Class implementing a rich text documentation viewer.
33    """
34    EmpytDocument_Light = (
35        '''<!DOCTYPE html>\n'''
36        '''<html lang="EN">\n'''
37        '''<head>\n'''
38        '''<style type="text/css">\n'''
39        '''html {background-color: #ffffff;}\n'''
40        '''body {background-color: #ffffff;\n'''
41        '''      color: #000000;\n'''
42        '''      margin: 0px 10px 10px 10px;\n'''
43        '''}\n'''
44        '''</style'''
45        '''</head>\n'''
46        '''<body>\n'''
47        '''</body>\n'''
48        '''</html>'''
49    )
50    EmpytDocument_Dark = (
51        '''<!DOCTYPE html>\n'''
52        '''<html lang="EN">\n'''
53        '''<head>\n'''
54        '''<style type="text/css">\n'''
55        '''html {background-color: #262626;}\n'''
56        '''body {background-color: #262626;\n'''
57        '''      color: #ffffff;\n'''
58        '''      margin: 0px 10px 10px 10px;\n'''
59        '''}\n'''
60        '''</style'''
61        '''</head>\n'''
62        '''<body>\n'''
63        '''</body>\n'''
64        '''</html>'''
65    )
66
67    def __init__(self, parent=None):
68        """
69        Constructor
70
71        @param parent reference to the parent widget
72        @type QWidget
73        """
74        super().__init__(parent)
75        self.setObjectName("DocumentationViewerWidget")
76
77        self.__verticalLayout = QVBoxLayout(self)
78        self.__verticalLayout.setObjectName("verticalLayout")
79        self.__verticalLayout.setContentsMargins(0, 0, 0, 0)
80
81        try:
82            from PyQt5.QtWebEngineWidgets import (
83                QWebEngineView, QWebEngineSettings
84            )
85            self.__contents = QWebEngineView(self)
86            self.__contents.page().linkHovered.connect(self.__showLink)
87            self.__contents.settings().setAttribute(
88                QWebEngineSettings.WebAttribute.FocusOnNavigationEnabled,
89                False)
90            self.__viewerType = E5TextEditType.QWEBENGINEVIEW
91        except ImportError:
92            self.__contents = QTextBrowser(self)
93            self.__contents.setOpenExternalLinks(True)
94            self.__viewerType = E5TextEditType.QTEXTBROWSER
95
96        sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred,
97                                 QSizePolicy.Policy.Expanding)
98        sizePolicy.setHorizontalStretch(0)
99        sizePolicy.setVerticalStretch(0)
100        sizePolicy.setHeightForWidth(
101            self.__contents.sizePolicy().hasHeightForWidth())
102        self.__contents.setSizePolicy(sizePolicy)
103        self.__contents.setContextMenuPolicy(
104            Qt.ContextMenuPolicy.NoContextMenu)
105        if self.__viewerType != E5TextEditType.QTEXTBROWSER:
106            self.__contents.setUrl(QUrl("about:blank"))
107        self.__verticalLayout.addWidget(self.__contents)
108
109        self.__searchWidget = E5TextEditSearchWidget(self, False)
110        self.__searchWidget.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
111        self.__searchWidget.setObjectName("searchWidget")
112        self.__verticalLayout.addWidget(self.__searchWidget)
113
114        self.__searchWidget.attachTextEdit(
115            self.__contents, editType=self.__viewerType)
116
117    @pyqtSlot(str)
118    def __showLink(self, urlStr):
119        """
120        Private slot to show the hovered link in a tooltip.
121
122        @param urlStr hovered URL
123        @type str
124        """
125        QToolTip.showText(QCursor.pos(), urlStr, self.__contents)
126
127    def setHtml(self, html):
128        """
129        Public method to set the HTML text of the widget.
130
131        @param html HTML text to be shown
132        @type str
133        """
134        self.__contents.setEnabled(False)
135        self.__contents.setHtml(html)
136        self.__contents.setEnabled(True)
137
138    def clear(self):
139        """
140        Public method to clear the shown contents.
141        """
142        if self.__viewerType == E5TextEditType.QTEXTBROWSER:
143            self.__contents.clear()
144        else:
145            if e5App().usesDarkPalette():
146                self.__contents.setHtml(self.EmpytDocument_Dark)
147            else:
148                self.__contents.setHtml(self.EmpytDocument_Light)
149
150
151class CodeDocumentationViewer(QWidget):
152    """
153    Class implementing a widget to show some source code information provided
154    by plug-ins.
155
156    @signal providerAdded() emitted to indicate the availability of a new
157        provider
158    @signal providerRemoved() emitted to indicate the removal of a provider
159    """
160    providerAdded = pyqtSignal()
161    providerRemoved = pyqtSignal()
162
163    def __init__(self, parent=None):
164        """
165        Constructor
166
167        @param parent reference to the parent widget
168        @type QWidget
169        """
170        super().__init__(parent)
171        self.__setupUi()
172
173        self.__ui = parent
174
175        self.__providers = {}
176        self.__selectedProvider = ""
177        self.__disabledProvider = "disabled"
178
179        self.__shuttingDown = False
180        self.__startingUp = True
181
182        self.__lastDocumentation = None
183        self.__requestingEditor = None
184
185        self.__unregisterTimer = QTimer(self)
186        self.__unregisterTimer.setInterval(30000)   # 30 seconds
187        self.__unregisterTimer.setSingleShot(True)
188        self.__unregisterTimer.timeout.connect(self.__unregisterTimerTimeout)
189        self.__mostRecentlyUnregisteredProvider = None
190
191    def __setupUi(self):
192        """
193        Private method to generate the UI layout.
194        """
195        self.setObjectName("CodeDocumentationViewer")
196
197        self.verticalLayout = QVBoxLayout(self)
198        self.verticalLayout.setObjectName("verticalLayout")
199        self.verticalLayout.setContentsMargins(3, 3, 3, 3)
200
201        # top row 1 of widgets
202        self.horizontalLayout1 = QHBoxLayout()
203        self.horizontalLayout1.setObjectName("horizontalLayout1")
204
205        self.label = QLabel(self)
206        self.label.setObjectName("label")
207        self.label.setText(self.tr("Code Info Provider:"))
208        self.label.setAlignment(Qt.AlignmentFlag.AlignRight |
209                                Qt.AlignmentFlag.AlignVCenter)
210        self.horizontalLayout1.addWidget(self.label)
211
212        self.providerComboBox = QComboBox(self)
213        sizePolicy = QSizePolicy(
214            QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
215        sizePolicy.setHorizontalStretch(0)
216        sizePolicy.setVerticalStretch(0)
217        sizePolicy.setHeightForWidth(
218            self.providerComboBox.sizePolicy().hasHeightForWidth())
219        self.providerComboBox.setSizePolicy(sizePolicy)
220        self.providerComboBox.setSizeAdjustPolicy(
221            QComboBox.SizeAdjustPolicy.AdjustToContents)
222        self.providerComboBox.setObjectName("providerComboBox")
223        self.providerComboBox.setToolTip(
224            self.tr("Select the code info provider"))
225        self.providerComboBox.addItem(self.tr("<disabled>"), "disabled")
226        self.horizontalLayout1.addWidget(self.providerComboBox)
227
228        # top row 2 of widgets
229        self.objectLineEdit = QLineEdit(self)
230        self.objectLineEdit.setReadOnly(True)
231        self.objectLineEdit.setObjectName("objectLineEdit")
232
233        self.verticalLayout.addLayout(self.horizontalLayout1)
234        self.verticalLayout.addWidget(self.objectLineEdit)
235
236        # Rich Text (Web) Viewer
237        self.__viewerWidget = DocumentationViewerWidget(self)
238        self.__viewerWidget.setObjectName("__viewerWidget")
239        self.verticalLayout.addWidget(self.__viewerWidget)
240
241        # backward compatibility for plug-ins before 2018-09-17
242        Preferences.setDocuViewer("ShowInfoAsRichText", True)
243
244        self.providerComboBox.currentIndexChanged[int].connect(
245            self.on_providerComboBox_currentIndexChanged)
246
247    def finalizeSetup(self):
248        """
249        Public method to finalize the setup of the documentation viewer.
250        """
251        self.__startingUp = False
252        provider = Preferences.getDocuViewer("Provider")
253        if provider in self.__providers:
254            index = self.providerComboBox.findData(provider)
255        else:
256            index = 0
257            provider = self.__disabledProvider
258        self.providerComboBox.setCurrentIndex(index)
259        self.__selectedProvider = provider
260        if index == 0:
261            self.__showDisabledMessage()
262
263    def registerProvider(self, providerName, providerDisplay, provider,
264                         supported):
265        """
266        Public method register a source docu provider.
267
268        @param providerName name of the provider (must be unique)
269        @type str
270        @param providerDisplay visible name of the provider
271        @type str
272        @param provider function to be called to determine source docu
273        @type function(editor)
274        @param supported function to be called to determine, if a language is
275            supported
276        @type function(language)
277        @exception KeyError raised if a provider with the given name was
278            already registered
279        """
280        if providerName in self.__providers:
281            raise KeyError(
282                "Provider '{0}' already registered.".format(providerName))
283
284        self.__providers[providerName] = (provider, supported)
285        self.providerComboBox.addItem(providerDisplay, providerName)
286
287        self.providerAdded.emit()
288
289        if (
290            self.__unregisterTimer.isActive() and
291            providerName == self.__mostRecentlyUnregisteredProvider
292        ):
293            # this is assumed to be a plug-in reload
294            self.__unregisterTimer.stop()
295            self.__mostRecentlyUnregisteredProvider = None
296            self.__selectProvider(providerName)
297
298    def unregisterProvider(self, providerName):
299        """
300        Public method register a source docu provider.
301
302        @param providerName name of the provider (must be unique)
303        @type str
304        """
305        if providerName in self.__providers:
306            if providerName == self.__selectedProvider:
307                self.providerComboBox.setCurrentIndex(0)
308
309                # in case this is just a temporary unregistration (< 30s)
310                # e.g. when the plug-in is re-installed or updated
311                self.__mostRecentlyUnregisteredProvider = providerName
312                self.__unregisterTimer.start()
313
314            del self.__providers[providerName]
315            index = self.providerComboBox.findData(providerName)
316            self.providerComboBox.removeItem(index)
317
318            self.providerRemoved.emit()
319
320    @pyqtSlot()
321    def __unregisterTimerTimeout(self):
322        """
323        Private slot handling the timeout signal of the unregister timer.
324        """
325        self.__mostRecentlyUnregisteredProvider = None
326
327    def isSupportedLanguage(self, language):
328        """
329        Public method to check, if the given language is supported by the
330        selected provider.
331
332        @param language editor programming language to check
333        @type str
334        @return flag indicating the support status
335        @rtype bool
336        """
337        supported = False
338
339        if self.__selectedProvider != self.__disabledProvider:
340            supported = self.__providers[self.__selectedProvider][1](language)
341
342        return supported
343
344    def getProviders(self):
345        """
346        Public method to get a list of providers and their visible strings.
347
348        @return list containing the providers and their visible strings
349        @rtype list of tuple of (str,str)
350        """
351        providers = []
352        for index in range(1, self.providerComboBox.count()):
353            provider = self.providerComboBox.itemData(index)
354            text = self.providerComboBox.itemText(index)
355            providers.append((provider, text))
356
357        return providers
358
359    def showInfo(self, editor):
360        """
361        Public method to request code documentation data from a provider.
362
363        @param editor reference to the editor to request code docu for
364        @type Editor
365        """
366        line, index = editor.getCursorPosition()
367        word = editor.getWord(line, index)
368        if not word:
369            # try again one index before
370            word = editor.getWord(line, index - 1)
371        self.objectLineEdit.setText(word)
372
373        if self.__selectedProvider != self.__disabledProvider:
374            self.__viewerWidget.clear()
375            self.__providers[self.__selectedProvider][0](editor)
376
377    def documentationReady(self, documentationInfo, isWarning=False,
378                           isDocWarning=False):
379        """
380        Public method to provide the documentation info to the viewer.
381
382        If documentationInfo is a dictionary, it should contain these
383        (optional) keys and data:
384
385        name: the name of the inspected object
386        argspec: its arguments specification
387        note: A phrase describing the type of object (function or method) and
388            the module it belongs to.
389        docstring: its documentation string
390        typ: its type information
391
392        @param documentationInfo dictionary containing the source docu data
393        @type dict or str
394        @param isWarning flag indicating a warning page
395        @type bool
396        @param isDocWarning flag indicating a documentation warning page
397        @type bool
398        """
399        self.__ui.activateCodeDocumentationViewer(switchFocus=False)
400
401        if not isWarning and not isDocWarning:
402            self.__lastDocumentation = documentationInfo
403
404        if not documentationInfo:
405            if self.__selectedProvider == self.__disabledProvider:
406                self.__showDisabledMessage()
407            else:
408                self.documentationReady(self.tr("No documentation available"),
409                                        isDocWarning=True)
410        else:
411            if isWarning:
412                html = prepareDocumentationViewerHtmlWarningDocument(
413                    documentationInfo)
414            elif isDocWarning:
415                html = prepareDocumentationViewerHtmlDocWarningDocument(
416                    documentationInfo)
417            elif isinstance(documentationInfo, dict):
418                html = prepareDocumentationViewerHtmlDocument(
419                    documentationInfo)
420            else:
421                html = documentationInfo
422            self.__viewerWidget.setHtml(html)
423
424    def __showDisabledMessage(self):
425        """
426        Private method to show a message giving the reason for being disabled.
427        """
428        if len(self.__providers) == 0:
429            self.documentationReady(
430                self.tr("No source code documentation provider has been"
431                        " registered. This function has been disabled."),
432                isWarning=True)
433        else:
434            self.documentationReady(
435                self.tr("This function has been disabled."),
436                isWarning=True)
437
438    @pyqtSlot(int)
439    def on_providerComboBox_currentIndexChanged(self, index):
440        """
441        Private slot to handle the selection of a provider.
442
443        @param index index of the selected provider
444        @type int
445        """
446        if not self.__shuttingDown and not self.__startingUp:
447            self.__viewerWidget.clear()
448            self.objectLineEdit.clear()
449
450            provider = self.providerComboBox.itemData(index)
451            if provider == self.__disabledProvider:
452                self.__showDisabledMessage()
453            else:
454                self.__lastDocumentation = None
455
456            Preferences.setDocuViewer("Provider", provider)
457            self.__selectedProvider = provider
458
459    def shutdown(self):
460        """
461        Public method to perform shutdown actions.
462        """
463        self.__shuttingDown = True
464        Preferences.setDocuViewer("Provider", self.__selectedProvider)
465
466    def preferencesChanged(self):
467        """
468        Public slot to handle a change of preferences.
469        """
470        provider = Preferences.getDocuViewer("Provider")
471        self.__selectProvider(provider)
472
473    def __selectProvider(self, provider):
474        """
475        Private method to select a provider programmatically.
476
477        @param provider name of the provider to be selected
478        @type str
479        """
480        if provider != self.__selectedProvider:
481            index = self.providerComboBox.findData(provider)
482            if index < 0:
483                index = 0
484            self.providerComboBox.setCurrentIndex(index)
485