1# -*- coding: utf-8 -*-
2#
3# Copyright © Spyder Project Contributors
4# Licensed under the terms of the MIT License
5# (see spyder/__init__.py for details)
6
7"""Help Plugin"""
8
9# Standard library imports
10import re
11import os.path as osp
12import socket
13import sys
14
15# Third party imports
16from qtpy import PYQT5
17from qtpy.QtCore import QThread, QUrl, Signal, Slot
18from qtpy.QtWidgets import (QActionGroup, QComboBox, QGroupBox, QHBoxLayout,
19                            QLabel, QLineEdit, QMenu, QMessageBox, QSizePolicy,
20                            QToolButton, QVBoxLayout, QWidget)
21from qtpy.QtWebEngineWidgets import QWebEnginePage, WEBENGINE
22
23# Local imports
24from spyder import dependencies
25from spyder.config.base import _, get_conf_path, get_module_source_path
26from spyder.config.fonts import DEFAULT_SMALL_DELTA
27from spyder.plugins import SpyderPluginWidget
28from spyder.plugins.configdialog import PluginConfigPage
29from spyder.py3compat import get_meth_class_inst, to_text_string
30from spyder.utils import icon_manager as ima
31from spyder.utils import programs
32from spyder.utils.help.sphinxify import (CSS_PATH, generate_context,
33                                         sphinxify, usage, warning)
34from spyder.utils.qthelpers import (add_actions, create_action,
35                                    create_toolbutton, create_plugin_layout)
36from spyder.widgets.browser import FrameWebView
37from spyder.widgets.comboboxes import EditableComboBox
38from spyder.widgets.findreplace import FindReplace
39from spyder.widgets.sourcecode import codeeditor
40
41
42# Sphinx dependency
43dependencies.add("sphinx", _("Show help for objects in the Editor and "
44                             "Consoles in a dedicated pane"),
45                 required_version='>=0.6.6')
46
47
48
49class ObjectComboBox(EditableComboBox):
50    """
51    QComboBox handling object names
52    """
53    # Signals
54    valid = Signal(bool, bool)
55
56    def __init__(self, parent):
57        EditableComboBox.__init__(self, parent)
58        self.help = parent
59        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
60        self.tips = {True: '', False: ''}
61
62    def is_valid(self, qstr=None):
63        """Return True if string is valid"""
64        if not self.help.source_is_console():
65            return True
66        if qstr is None:
67            qstr = self.currentText()
68        if not re.search(r'^[a-zA-Z0-9_\.]*$', str(qstr), 0):
69            return False
70        objtxt = to_text_string(qstr)
71        if self.help.get_option('automatic_import'):
72            shell = self.help.internal_shell
73            if shell is not None:
74                return shell.is_defined(objtxt, force_import=True)
75        shell = self.help.get_shell()
76        if shell is not None:
77            try:
78                return shell.is_defined(objtxt)
79            except socket.error:
80                shell = self.help.get_shell()
81                try:
82                    return shell.is_defined(objtxt)
83                except socket.error:
84                    # Well... too bad!
85                    pass
86
87    def validate_current_text(self):
88        self.validate(self.currentText())
89
90    def validate(self, qstr, editing=True):
91        """Reimplemented to avoid formatting actions"""
92        valid = self.is_valid(qstr)
93        if self.hasFocus() and valid is not None:
94            if editing:
95                # Combo box text is being modified: invalidate the entry
96                self.show_tip(self.tips[valid])
97                self.valid.emit(False, False)
98            else:
99                # A new item has just been selected
100                if valid:
101                    self.selected()
102                else:
103                    self.valid.emit(False, False)
104
105
106class HelpConfigPage(PluginConfigPage):
107    def setup_page(self):
108        # Connections group
109        connections_group = QGroupBox(_("Automatic connections"))
110        connections_label = QLabel(_("This pane can automatically "
111                                     "show an object's help information after "
112                                     "a left parenthesis is written next to it. "
113                                     "Below you can decide to which plugin "
114                                     "you want to connect it to turn on this "
115                                     "feature."))
116        connections_label.setWordWrap(True)
117        editor_box = self.create_checkbox(_("Editor"), 'connect/editor')
118        rope_installed = programs.is_module_installed('rope')
119        jedi_installed = programs.is_module_installed('jedi', '>=0.8.1')
120        editor_box.setEnabled(rope_installed or jedi_installed)
121        if not rope_installed and not jedi_installed:
122            editor_tip = _("This feature requires the Rope or Jedi libraries.\n"
123                           "It seems you don't have either installed.")
124            editor_box.setToolTip(editor_tip)
125        ipython_box = self.create_checkbox(_("IPython Console"),
126                                           'connect/ipython_console')
127
128        connections_layout = QVBoxLayout()
129        connections_layout.addWidget(connections_label)
130        connections_layout.addWidget(editor_box)
131        connections_layout.addWidget(ipython_box)
132        connections_group.setLayout(connections_layout)
133
134        # Features group
135        features_group = QGroupBox(_("Additional features"))
136        math_box = self.create_checkbox(_("Render mathematical equations"),
137                                        'math')
138        req_sphinx = programs.is_module_installed('sphinx', '>=1.1')
139        math_box.setEnabled(req_sphinx)
140        if not req_sphinx:
141            sphinx_ver = programs.get_module_version('sphinx')
142            sphinx_tip = _("This feature requires Sphinx 1.1 or superior.")
143            sphinx_tip += "\n" + _("Sphinx %s is currently installed.") % sphinx_ver
144            math_box.setToolTip(sphinx_tip)
145
146        features_layout = QVBoxLayout()
147        features_layout.addWidget(math_box)
148        features_group.setLayout(features_layout)
149
150        # Source code group
151        sourcecode_group = QGroupBox(_("Source code"))
152        wrap_mode_box = self.create_checkbox(_("Wrap lines"), 'wrap')
153
154        sourcecode_layout = QVBoxLayout()
155        sourcecode_layout.addWidget(wrap_mode_box)
156        sourcecode_group.setLayout(sourcecode_layout)
157
158        # Final layout
159        vlayout = QVBoxLayout()
160        vlayout.addWidget(connections_group)
161        vlayout.addWidget(features_group)
162        vlayout.addWidget(sourcecode_group)
163        vlayout.addStretch(1)
164        self.setLayout(vlayout)
165
166
167class RichText(QWidget):
168    """
169    WebView widget with find dialog
170    """
171    def __init__(self, parent):
172        QWidget.__init__(self, parent)
173
174        self.webview = FrameWebView(self)
175        self.find_widget = FindReplace(self)
176        self.find_widget.set_editor(self.webview.web_widget)
177        self.find_widget.hide()
178
179        layout = QVBoxLayout()
180        layout.setContentsMargins(0, 0, 0, 0)
181        layout.addWidget(self.webview)
182        layout.addWidget(self.find_widget)
183        self.setLayout(layout)
184
185    def set_font(self, font, fixed_font=None):
186        """Set font"""
187        self.webview.set_font(font, fixed_font=fixed_font)
188
189    def set_html(self, html_text, base_url):
190        """Set html text"""
191        self.webview.setHtml(html_text, base_url)
192
193    def clear(self):
194        self.set_html('', self.webview.url())
195
196
197class PlainText(QWidget):
198    """
199    Read-only editor widget with find dialog
200    """
201    # Signals
202    focus_changed = Signal()
203
204    def __init__(self, parent):
205        QWidget.__init__(self, parent)
206        self.editor = None
207
208        # Read-only editor
209        self.editor = codeeditor.CodeEditor(self)
210        self.editor.setup_editor(linenumbers=False, language='py',
211                                 scrollflagarea=False, edge_line=False)
212        self.editor.focus_changed.connect(lambda: self.focus_changed.emit())
213        self.editor.setReadOnly(True)
214
215        # Find/replace widget
216        self.find_widget = FindReplace(self)
217        self.find_widget.set_editor(self.editor)
218        self.find_widget.hide()
219
220        layout = QVBoxLayout()
221        layout.setContentsMargins(0, 0, 0, 0)
222        layout.addWidget(self.editor)
223        layout.addWidget(self.find_widget)
224        self.setLayout(layout)
225
226    def set_font(self, font, color_scheme=None):
227        """Set font"""
228        self.editor.set_font(font, color_scheme=color_scheme)
229
230    def set_color_scheme(self, color_scheme):
231        """Set color scheme"""
232        self.editor.set_color_scheme(color_scheme)
233
234    def set_text(self, text, is_code):
235        self.editor.set_highlight_current_line(is_code)
236        self.editor.set_occurrence_highlighting(is_code)
237        if is_code:
238            self.editor.set_language('py')
239        else:
240            self.editor.set_language(None)
241        self.editor.set_text(text)
242        self.editor.set_cursor_position('sof')
243
244    def clear(self):
245        self.editor.clear()
246
247
248class SphinxThread(QThread):
249    """
250    A worker thread for handling rich text rendering.
251
252    Parameters
253    ----------
254    doc : str or dict
255        A string containing a raw rst text or a dict containing
256        the doc string components to be rendered.
257        See spyder.utils.dochelpers.getdoc for description.
258    context : dict
259        A dict containing the substitution variables for the
260        layout template
261    html_text_no_doc : unicode
262        Text to be rendered if doc string cannot be extracted.
263    math_option : bool
264        Use LaTeX math rendering.
265
266    """
267    # Signals
268    error_msg = Signal(str)
269    html_ready = Signal(str)
270
271    def __init__(self, html_text_no_doc=''):
272        super(SphinxThread, self).__init__()
273        self.doc = None
274        self.context = None
275        self.html_text_no_doc = html_text_no_doc
276        self.math_option = False
277
278    def render(self, doc, context=None, math_option=False, img_path=''):
279        """Start thread to render a given documentation"""
280        # If the thread is already running wait for it to finish before
281        # starting it again.
282        if self.wait():
283            self.doc = doc
284            self.context = context
285            self.math_option = math_option
286            self.img_path = img_path
287            # This causes run() to be executed in separate thread
288            self.start()
289
290    def run(self):
291        html_text = self.html_text_no_doc
292        doc = self.doc
293        if doc is not None:
294            if type(doc) is dict and 'docstring' in doc.keys():
295                try:
296                    context = generate_context(name=doc['name'],
297                                               argspec=doc['argspec'],
298                                               note=doc['note'],
299                                               math=self.math_option,
300                                               img_path=self.img_path)
301                    html_text = sphinxify(doc['docstring'], context)
302                    if doc['docstring'] == '':
303                        if any([doc['name'], doc['argspec'], doc['note']]):
304                            msg = _("No further documentation available")
305                            html_text += '<div class="hr"></div>'
306                        else:
307                            msg = _("No documentation available")
308                        html_text += '<div id="doc-warning">%s</div>' % msg
309                except Exception as error:
310                    self.error_msg.emit(to_text_string(error))
311                    return
312            elif self.context is not None:
313                try:
314                    html_text = sphinxify(doc, self.context)
315                except Exception as error:
316                    self.error_msg.emit(to_text_string(error))
317                    return
318        self.html_ready.emit(html_text)
319
320
321class Help(SpyderPluginWidget):
322    """
323    Docstrings viewer widget
324    """
325    CONF_SECTION = 'help'
326    CONFIGWIDGET_CLASS = HelpConfigPage
327    LOG_PATH = get_conf_path(CONF_SECTION)
328    FONT_SIZE_DELTA = DEFAULT_SMALL_DELTA
329
330    # Signals
331    focus_changed = Signal()
332
333    def __init__(self, parent=None):
334        if PYQT5:
335            SpyderPluginWidget.__init__(self, parent, main = parent)
336        else:
337            SpyderPluginWidget.__init__(self, parent)
338
339        self.internal_shell = None
340        self.console = None
341        self.ipyconsole = None
342        self.editor = None
343
344        # Initialize plugin
345        self.initialize_plugin()
346
347        self.no_doc_string = _("No documentation available")
348
349        self._last_console_cb = None
350        self._last_editor_cb = None
351
352        self.plain_text = PlainText(self)
353        self.rich_text = RichText(self)
354
355        color_scheme = self.get_color_scheme()
356        self.set_plain_text_font(self.get_plugin_font(), color_scheme)
357        self.plain_text.editor.toggle_wrap_mode(self.get_option('wrap'))
358
359        # Add entries to read-only editor context-menu
360        self.wrap_action = create_action(self, _("Wrap lines"),
361                                         toggled=self.toggle_wrap_mode)
362        self.wrap_action.setChecked(self.get_option('wrap'))
363        self.plain_text.editor.readonly_menu.addSeparator()
364        add_actions(self.plain_text.editor.readonly_menu, (self.wrap_action,))
365
366        self.set_rich_text_font(self.get_plugin_font('rich_text'))
367
368        self.shell = None
369
370        # locked = disable link with Console
371        self.locked = False
372        self._last_texts = [None, None]
373        self._last_editor_doc = None
374
375        # Object name
376        layout_edit = QHBoxLayout()
377        layout_edit.setContentsMargins(0, 0, 0, 0)
378        txt = _("Source")
379        if sys.platform == 'darwin':
380            source_label = QLabel("  " + txt)
381        else:
382            source_label = QLabel(txt)
383        layout_edit.addWidget(source_label)
384        self.source_combo = QComboBox(self)
385        self.source_combo.addItems([_("Console"), _("Editor")])
386        self.source_combo.currentIndexChanged.connect(self.source_changed)
387        if (not programs.is_module_installed('rope') and
388                not programs.is_module_installed('jedi', '>=0.8.1')):
389            self.source_combo.hide()
390            source_label.hide()
391        layout_edit.addWidget(self.source_combo)
392        layout_edit.addSpacing(10)
393        layout_edit.addWidget(QLabel(_("Object")))
394        self.combo = ObjectComboBox(self)
395        layout_edit.addWidget(self.combo)
396        self.object_edit = QLineEdit(self)
397        self.object_edit.setReadOnly(True)
398        layout_edit.addWidget(self.object_edit)
399        self.combo.setMaxCount(self.get_option('max_history_entries'))
400        self.combo.addItems( self.load_history() )
401        self.combo.setItemText(0, '')
402        self.combo.valid.connect(lambda valid: self.force_refresh())
403
404        # Plain text docstring option
405        self.docstring = True
406        self.rich_help = self.get_option('rich_mode', True)
407        self.plain_text_action = create_action(self, _("Plain Text"),
408                                               toggled=self.toggle_plain_text)
409
410        # Source code option
411        self.show_source_action = create_action(self, _("Show Source"),
412                                                toggled=self.toggle_show_source)
413
414        # Rich text option
415        self.rich_text_action = create_action(self, _("Rich Text"),
416                                         toggled=self.toggle_rich_text)
417
418        # Add the help actions to an exclusive QActionGroup
419        help_actions = QActionGroup(self)
420        help_actions.setExclusive(True)
421        help_actions.addAction(self.plain_text_action)
422        help_actions.addAction(self.rich_text_action)
423
424        # Automatic import option
425        self.auto_import_action = create_action(self, _("Automatic import"),
426                                                toggled=self.toggle_auto_import)
427        auto_import_state = self.get_option('automatic_import')
428        self.auto_import_action.setChecked(auto_import_state)
429
430        # Lock checkbox
431        self.locked_button = create_toolbutton(self,
432                                               triggered=self.toggle_locked)
433        layout_edit.addWidget(self.locked_button)
434        self._update_lock_icon()
435
436        # Option menu
437        options_button = create_toolbutton(self, text=_('Options'),
438                                           icon=ima.icon('tooloptions'))
439        options_button.setPopupMode(QToolButton.InstantPopup)
440        menu = QMenu(self)
441        add_actions(menu, [self.rich_text_action, self.plain_text_action,
442                           self.show_source_action, None,
443                           self.auto_import_action])
444        options_button.setMenu(menu)
445        layout_edit.addWidget(options_button)
446
447        if self.rich_help:
448            self.switch_to_rich_text()
449        else:
450            self.switch_to_plain_text()
451        self.plain_text_action.setChecked(not self.rich_help)
452        self.rich_text_action.setChecked(self.rich_help)
453        self.source_changed()
454
455        # Main layout
456        layout = create_plugin_layout(layout_edit)
457        # we have two main widgets, but only one of them is shown at a time
458        layout.addWidget(self.plain_text)
459        layout.addWidget(self.rich_text)
460        self.setLayout(layout)
461
462        # Add worker thread for handling rich text rendering
463        self._sphinx_thread = SphinxThread(
464                                  html_text_no_doc=warning(self.no_doc_string))
465        self._sphinx_thread.html_ready.connect(
466                                             self._on_sphinx_thread_html_ready)
467        self._sphinx_thread.error_msg.connect(self._on_sphinx_thread_error_msg)
468
469        # Handle internal and external links
470        view = self.rich_text.webview
471        if not WEBENGINE:
472            view.page().setLinkDelegationPolicy(QWebEnginePage.DelegateAllLinks)
473        view.linkClicked.connect(self.handle_link_clicks)
474
475        self._starting_up = True
476
477    #------ SpyderPluginWidget API ---------------------------------------------
478    def on_first_registration(self):
479        """Action to be performed on first plugin registration"""
480        self.main.tabify_plugins(self.main.variableexplorer, self)
481
482    def get_plugin_title(self):
483        """Return widget title"""
484        return _('Help')
485
486    def get_plugin_icon(self):
487        """Return widget icon"""
488        return ima.icon('help')
489
490    def get_focus_widget(self):
491        """
492        Return the widget to give focus to when
493        this plugin's dockwidget is raised on top-level
494        """
495        self.combo.lineEdit().selectAll()
496        return self.combo
497
498    def get_plugin_actions(self):
499        """Return a list of actions related to plugin"""
500        return []
501
502    def register_plugin(self):
503        """Register plugin in Spyder's main window"""
504        self.focus_changed.connect(self.main.plugin_focus_changed)
505        self.main.add_dockwidget(self)
506        self.main.console.set_help(self)
507
508        self.internal_shell = self.main.console.shell
509        self.console = self.main.console
510        self.ipyconsole = self.main.ipyconsole
511        self.editor = self.main.editor
512
513    def closing_plugin(self, cancelable=False):
514        """Perform actions before parent main window is closed"""
515        return True
516
517    def refresh_plugin(self):
518        """Refresh widget"""
519        if self._starting_up:
520            self._starting_up = False
521            self.switch_to_rich_text()
522            self.show_intro_message()
523
524    def update_font(self):
525        """Update font from Preferences"""
526        color_scheme = self.get_color_scheme()
527        font = self.get_plugin_font()
528        rich_font = self.get_plugin_font(rich_text=True)
529
530        self.set_plain_text_font(font, color_scheme=color_scheme)
531        self.set_rich_text_font(rich_font)
532
533    def apply_plugin_settings(self, options):
534        """Apply configuration file's plugin settings"""
535        color_scheme_n = 'color_scheme_name'
536        color_scheme_o = self.get_color_scheme()
537        connect_n = 'connect_to_oi'
538        wrap_n = 'wrap'
539        wrap_o = self.get_option(wrap_n)
540        self.wrap_action.setChecked(wrap_o)
541        math_n = 'math'
542        math_o = self.get_option(math_n)
543
544        if color_scheme_n in options:
545            self.set_plain_text_color_scheme(color_scheme_o)
546        if wrap_n in options:
547            self.toggle_wrap_mode(wrap_o)
548        if math_n in options:
549            self.toggle_math_mode(math_o)
550
551        # To make auto-connection changes take place instantly
552        self.editor.apply_plugin_settings(options=[connect_n])
553        self.ipyconsole.apply_plugin_settings(options=[connect_n])
554
555    #------ Public API (related to Help's source) -------------------------
556    def source_is_console(self):
557        """Return True if source is Console"""
558        return self.source_combo.currentIndex() == 0
559
560    def switch_to_editor_source(self):
561        self.source_combo.setCurrentIndex(1)
562
563    def switch_to_console_source(self):
564        self.source_combo.setCurrentIndex(0)
565
566    def source_changed(self, index=None):
567        if self.source_is_console():
568            # Console
569            self.combo.show()
570            self.object_edit.hide()
571            self.show_source_action.setEnabled(True)
572            self.auto_import_action.setEnabled(True)
573        else:
574            # Editor
575            self.combo.hide()
576            self.object_edit.show()
577            self.show_source_action.setDisabled(True)
578            self.auto_import_action.setDisabled(True)
579        self.restore_text()
580
581    def save_text(self, callback):
582        if self.source_is_console():
583            self._last_console_cb = callback
584        else:
585            self._last_editor_cb = callback
586
587    def restore_text(self):
588        if self.source_is_console():
589            cb = self._last_console_cb
590        else:
591            cb = self._last_editor_cb
592        if cb is None:
593            if self.is_plain_text_mode():
594                self.plain_text.clear()
595            else:
596                self.rich_text.clear()
597        else:
598            func = cb[0]
599            args = cb[1:]
600            func(*args)
601            if get_meth_class_inst(func) is self.rich_text:
602                self.switch_to_rich_text()
603            else:
604                self.switch_to_plain_text()
605
606    #------ Public API (related to rich/plain text widgets) --------------------
607    @property
608    def find_widget(self):
609        if self.plain_text.isVisible():
610            return self.plain_text.find_widget
611        else:
612            return self.rich_text.find_widget
613
614    def set_rich_text_font(self, font):
615        """Set rich text mode font"""
616        self.rich_text.set_font(font, fixed_font=self.get_plugin_font())
617
618    def set_plain_text_font(self, font, color_scheme=None):
619        """Set plain text mode font"""
620        self.plain_text.set_font(font, color_scheme=color_scheme)
621
622    def set_plain_text_color_scheme(self, color_scheme):
623        """Set plain text mode color scheme"""
624        self.plain_text.set_color_scheme(color_scheme)
625
626    @Slot(bool)
627    def toggle_wrap_mode(self, checked):
628        """Toggle wrap mode"""
629        self.plain_text.editor.toggle_wrap_mode(checked)
630        self.set_option('wrap', checked)
631
632    def toggle_math_mode(self, checked):
633        """Toggle math mode"""
634        self.set_option('math', checked)
635
636    def is_plain_text_mode(self):
637        """Return True if plain text mode is active"""
638        return self.plain_text.isVisible()
639
640    def is_rich_text_mode(self):
641        """Return True if rich text mode is active"""
642        return self.rich_text.isVisible()
643
644    def switch_to_plain_text(self):
645        """Switch to plain text mode"""
646        self.rich_help = False
647        self.plain_text.show()
648        self.rich_text.hide()
649        self.plain_text_action.setChecked(True)
650
651    def switch_to_rich_text(self):
652        """Switch to rich text mode"""
653        self.rich_help = True
654        self.plain_text.hide()
655        self.rich_text.show()
656        self.rich_text_action.setChecked(True)
657        self.show_source_action.setChecked(False)
658
659    def set_plain_text(self, text, is_code):
660        """Set plain text docs"""
661
662        # text is coming from utils.dochelpers.getdoc
663        if type(text) is dict:
664            name = text['name']
665            if name:
666                rst_title = ''.join(['='*len(name), '\n', name, '\n',
667                                    '='*len(name), '\n\n'])
668            else:
669                rst_title = ''
670
671            if text['argspec']:
672                definition = ''.join(['Definition: ', name, text['argspec'],
673                                      '\n'])
674            else:
675                definition = ''
676
677            if text['note']:
678                note = ''.join(['Type: ', text['note'], '\n\n----\n\n'])
679            else:
680                note = ''
681
682            full_text = ''.join([rst_title, definition, note,
683                                 text['docstring']])
684        else:
685            full_text = text
686
687        self.plain_text.set_text(full_text, is_code)
688        self.save_text([self.plain_text.set_text, full_text, is_code])
689
690    def set_rich_text_html(self, html_text, base_url):
691        """Set rich text"""
692        self.rich_text.set_html(html_text, base_url)
693        self.save_text([self.rich_text.set_html, html_text, base_url])
694
695    def show_intro_message(self):
696        intro_message = _("Here you can get help of any object by pressing "
697                          "%s in front of it, either on the Editor or the "
698                          "Console.%s"
699                          "Help can also be shown automatically after writing "
700                          "a left parenthesis next to an object. You can "
701                          "activate this behavior in %s.")
702        prefs = _("Preferences > Help")
703        if sys.platform == 'darwin':
704            shortcut = "Cmd+I"
705        else:
706            shortcut = "Ctrl+I"
707
708        if self.is_rich_text_mode():
709            title = _("Usage")
710            tutorial_message = _("New to Spyder? Read our")
711            tutorial = _("tutorial")
712            intro_message = intro_message % ("<b>"+shortcut+"</b>", "<br><br>",
713                                             "<i>"+prefs+"</i>")
714            self.set_rich_text_html(usage(title, intro_message,
715                                          tutorial_message, tutorial),
716                                    QUrl.fromLocalFile(CSS_PATH))
717        else:
718            install_sphinx = "\n\n%s" % _("Please consider installing Sphinx "
719                                          "to get documentation rendered in "
720                                          "rich text.")
721            intro_message = intro_message % (shortcut, "\n\n", prefs)
722            intro_message += install_sphinx
723            self.set_plain_text(intro_message, is_code=False)
724
725    def show_rich_text(self, text, collapse=False, img_path=''):
726        """Show text in rich mode"""
727        self.visibility_changed(True)
728        self.raise_()
729        self.switch_to_rich_text()
730        context = generate_context(collapse=collapse, img_path=img_path)
731        self.render_sphinx_doc(text, context)
732
733    def show_plain_text(self, text):
734        """Show text in plain mode"""
735        self.visibility_changed(True)
736        self.raise_()
737        self.switch_to_plain_text()
738        self.set_plain_text(text, is_code=False)
739
740    @Slot()
741    def show_tutorial(self):
742        """Show the Spyder tutorial in the Help plugin, opening it if needed"""
743        if not self.dockwidget.isVisible():
744            self.dockwidget.show()
745            self.toggle_view_action.setChecked(True)
746        tutorial_path = get_module_source_path('spyder.utils.help')
747        tutorial = osp.join(tutorial_path, 'tutorial.rst')
748        text = open(tutorial).read()
749        self.show_rich_text(text, collapse=True)
750
751    def handle_link_clicks(self, url):
752        url = to_text_string(url.toString())
753        if url == "spy://tutorial":
754            self.show_tutorial()
755        elif url.startswith('http'):
756            programs.start_file(url)
757        else:
758            self.rich_text.webview.load(QUrl(url))
759
760    #------ Public API ---------------------------------------------------------
761    def force_refresh(self):
762        if self.source_is_console():
763            self.set_object_text(None, force_refresh=True)
764        elif self._last_editor_doc is not None:
765            self.set_editor_doc(self._last_editor_doc, force_refresh=True)
766
767    def set_object_text(self, text, force_refresh=False, ignore_unknown=False):
768        """Set object analyzed by Help"""
769        if (self.locked and not force_refresh):
770            return
771        self.switch_to_console_source()
772
773        add_to_combo = True
774        if text is None:
775            text = to_text_string(self.combo.currentText())
776            add_to_combo = False
777
778        found = self.show_help(text, ignore_unknown=ignore_unknown)
779        if ignore_unknown and not found:
780            return
781
782        if add_to_combo:
783            self.combo.add_text(text)
784        if found:
785            self.save_history()
786
787        if self.dockwidget is not None:
788            self.dockwidget.blockSignals(True)
789        self.__eventually_raise_help(text, force=force_refresh)
790        if self.dockwidget is not None:
791            self.dockwidget.blockSignals(False)
792
793    def set_editor_doc(self, doc, force_refresh=False):
794        """
795        Use the help plugin to show docstring dictionary computed
796        with introspection plugin from the Editor plugin
797        """
798        if (self.locked and not force_refresh):
799            return
800        self.switch_to_editor_source()
801        self._last_editor_doc = doc
802        self.object_edit.setText(doc['obj_text'])
803
804        if self.rich_help:
805            self.render_sphinx_doc(doc)
806        else:
807            self.set_plain_text(doc, is_code=False)
808
809        if self.dockwidget is not None:
810            self.dockwidget.blockSignals(True)
811        self.__eventually_raise_help(doc['docstring'], force=force_refresh)
812        if self.dockwidget is not None:
813            self.dockwidget.blockSignals(False)
814
815    def __eventually_raise_help(self, text, force=False):
816        index = self.source_combo.currentIndex()
817        if hasattr(self.main, 'tabifiedDockWidgets'):
818            # 'QMainWindow.tabifiedDockWidgets' was introduced in PyQt 4.5
819            if (self.dockwidget and (force or self.dockwidget.isVisible()) and
820                    not self.ismaximized and
821                    (force or text != self._last_texts[index])):
822                dockwidgets = self.main.tabifiedDockWidgets(self.dockwidget)
823                if (self.console.dockwidget not in dockwidgets and
824                        self.ipyconsole is not None and
825                        self.ipyconsole.dockwidget not in dockwidgets):
826                    self.dockwidget.show()
827                    self.dockwidget.raise_()
828        self._last_texts[index] = text
829
830    def load_history(self, obj=None):
831        """Load history from a text file in user home directory"""
832        if osp.isfile(self.LOG_PATH):
833            history = [line.replace('\n', '')
834                       for line in open(self.LOG_PATH, 'r').readlines()]
835        else:
836            history = []
837        return history
838
839    def save_history(self):
840        """Save history to a text file in user home directory"""
841        open(self.LOG_PATH, 'w').write("\n".join( \
842                [to_text_string(self.combo.itemText(index))
843                 for index in range(self.combo.count())] ))
844
845    @Slot(bool)
846    def toggle_plain_text(self, checked):
847        """Toggle plain text docstring"""
848        if checked:
849            self.docstring = checked
850            self.switch_to_plain_text()
851            self.force_refresh()
852        self.set_option('rich_mode', not checked)
853
854    @Slot(bool)
855    def toggle_show_source(self, checked):
856        """Toggle show source code"""
857        if checked:
858            self.switch_to_plain_text()
859        self.docstring = not checked
860        self.force_refresh()
861        self.set_option('rich_mode', not checked)
862
863    @Slot(bool)
864    def toggle_rich_text(self, checked):
865        """Toggle between sphinxified docstrings or plain ones"""
866        if checked:
867            self.docstring = not checked
868            self.switch_to_rich_text()
869        self.set_option('rich_mode', checked)
870
871    @Slot(bool)
872    def toggle_auto_import(self, checked):
873        """Toggle automatic import feature"""
874        self.combo.validate_current_text()
875        self.set_option('automatic_import', checked)
876        self.force_refresh()
877
878    @Slot()
879    def toggle_locked(self):
880        """
881        Toggle locked state
882        locked = disable link with Console
883        """
884        self.locked = not self.locked
885        self._update_lock_icon()
886
887    def _update_lock_icon(self):
888        """Update locked state icon"""
889        icon = ima.icon('lock') if self.locked else ima.icon('lock_open')
890        self.locked_button.setIcon(icon)
891        tip = _("Unlock") if self.locked else _("Lock")
892        self.locked_button.setToolTip(tip)
893
894    def set_shell(self, shell):
895        """Bind to shell"""
896        self.shell = shell
897
898    def get_shell(self):
899        """
900        Return shell which is currently bound to Help,
901        or another running shell if it has been terminated
902        """
903        if (not hasattr(self.shell, 'get_doc') or
904                (hasattr(self.shell, 'is_running') and
905                 not self.shell.is_running())):
906            self.shell = None
907            if self.ipyconsole is not None:
908                shell = self.ipyconsole.get_current_shellwidget()
909                if shell is not None and shell.kernel_client is not None:
910                    self.shell = shell
911            if self.shell is None:
912                self.shell = self.internal_shell
913        return self.shell
914
915    def render_sphinx_doc(self, doc, context=None):
916        """Transform doc string dictionary to HTML and show it"""
917        # Math rendering option could have changed
918        if self.editor is not None:
919            fname = self.editor.get_current_filename()
920            dname = osp.dirname(fname)
921        else:
922            dname = ''
923        self._sphinx_thread.render(doc, context, self.get_option('math'),
924                                   dname)
925
926    def _on_sphinx_thread_html_ready(self, html_text):
927        """Set our sphinx documentation based on thread result"""
928        self._sphinx_thread.wait()
929        self.set_rich_text_html(html_text, QUrl.fromLocalFile(CSS_PATH))
930
931    def _on_sphinx_thread_error_msg(self, error_msg):
932        """ Display error message on Sphinx rich text failure"""
933        self._sphinx_thread.wait()
934        self.plain_text_action.setChecked(True)
935        sphinx_ver = programs.get_module_version('sphinx')
936        QMessageBox.critical(self,
937                    _('Help'),
938                    _("The following error occured when calling "
939                      "<b>Sphinx %s</b>. <br>Incompatible Sphinx "
940                      "version or doc string decoding failed."
941                      "<br><br>Error message:<br>%s"
942                      ) % (sphinx_ver, error_msg))
943
944    def show_help(self, obj_text, ignore_unknown=False):
945        """Show help"""
946        shell = self.get_shell()
947        if shell is None:
948            return
949        obj_text = to_text_string(obj_text)
950
951        if not shell.is_defined(obj_text):
952            if self.get_option('automatic_import') and \
953               self.internal_shell.is_defined(obj_text, force_import=True):
954                shell = self.internal_shell
955            else:
956                shell = None
957                doc = None
958                source_text = None
959
960        if shell is not None:
961            doc = shell.get_doc(obj_text)
962            source_text = shell.get_source(obj_text)
963
964        is_code = False
965
966        if self.rich_help:
967            self.render_sphinx_doc(doc)
968            return doc is not None
969        elif self.docstring:
970            hlp_text = doc
971            if hlp_text is None:
972                hlp_text = source_text
973                if hlp_text is None:
974                    hlp_text = self.no_doc_string
975                    if ignore_unknown:
976                        return False
977        else:
978            hlp_text = source_text
979            if hlp_text is None:
980                hlp_text = doc
981                if hlp_text is None:
982                    hlp_text = _("No source code available.")
983                    if ignore_unknown:
984                        return False
985            else:
986                is_code = True
987        self.set_plain_text(hlp_text, is_code=is_code)
988        return True
989