# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
#
"""
Help plugin widgets.
"""

# Standard library imports
import os
import re
import socket
import sys

# Third party imports
from qtpy import PYQT5, PYQT6
from qtpy.QtCore import Qt, QUrl, Signal, Slot, QPoint
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (QActionGroup, QLabel, QLineEdit,
                            QMessageBox, QSizePolicy, QStackedWidget,
                            QVBoxLayout, QWidget)

# Local imports
from spyder.api.config.decorators import on_conf_change
from spyder.api.translations import _
from spyder.api.widgets.comboboxes import SpyderComboBox
from spyder.api.widgets.main_widget import PluginMainWidget
from spyder.api.widgets.mixins import SpyderWidgetMixin
from spyder.config.base import get_module_source_path
from spyder.plugins.help.utils.sphinxify import (CSS_PATH, generate_context,
                                                 loading, usage, warning)
from spyder.plugins.help.utils.sphinxthread import SphinxThread
from spyder.py3compat import to_text_string
from spyder.utils import programs
from spyder.utils.image_path_manager import get_image_path
from spyder.utils.palette import SpyderPalette
from spyder.utils.qthelpers import start_file
from spyder.widgets.comboboxes import EditableComboBox
from spyder.widgets.findreplace import FindReplace
from spyder.widgets.simplecodeeditor import SimpleCodeEditor

# In case WebEngine is not available (e.g. in Conda-forge)
try:
    from qtpy.QtWebEngineWidgets import WEBENGINE
except ImportError:
    WEBENGINE = False


# --- Constants
# ----------------------------------------------------------------------------
MAIN_BG_COLOR = SpyderPalette.COLOR_BACKGROUND_1


class HelpWidgetActions:
    # Toggles
    ToggleAutomaticImport = 'toggle_automatic_import_action'
    ToggleLocked = 'toggle_locked_action'
    TogglePlainMode = 'toggle_plain_mode_action'
    ToggleRichMode = 'toggle_rich_mode_action'
    ToggleShowSource = 'toggle_show_source_action'
    ToggleWrap = 'toggle_wrap_action'
    CopyAction = "help_widget_copy_action"
    SelectAll = "select_all_action",
    Home = 'home_action'


class HelpWidgetOptionsMenuSections:
    Display = 'display_section'
    Other = 'other_section'


class HelpWidgetMainToolbarSections:
    Main = 'main_section'


class HelpWidgetToolbarItems:
    SourceLabel = 'source_label'
    SourceCombo = 'source_combo'
    ObjectLabel = 'object_label'
    ObjectCombo = 'object_combo'
    ObjectEdit = 'object_edit'


# --- Widgets
# ----------------------------------------------------------------------------
class ObjectComboBox(EditableComboBox):
    """
    QComboBox handling object names
    """
    # Signals
    valid = Signal(bool, bool)

    def __init__(self, parent, id_=None):
        EditableComboBox.__init__(self, parent)
        self.help = parent
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        self.tips = {True: '', False: ''}

        if id_ is not None:
            self.ID = id_

    def is_valid(self, qstr=None):
        """Return True if string is valid"""
        if not self.help.source_is_console():
            return True
        if qstr is None:
            qstr = self.currentText()
        if not re.search(r'^[a-zA-Z0-9_\.]*$', str(qstr), 0):
            return False
        objtxt = to_text_string(qstr)
        shell_is_defined = False
        if self.help.get_conf('automatic_import'):
            shell = self.help.internal_shell
            if shell is not None:
                shell_is_defined = shell.is_defined(objtxt, force_import=True)
        if not shell_is_defined:
            shell = self.help.get_shell()
            if shell is not None:
                try:
                    shell_is_defined = shell.is_defined(objtxt)
                except socket.error:
                    shell = self.help.get_shell()
                    try:
                        shell_is_defined = shell.is_defined(objtxt)
                    except socket.error:
                        # Well... too bad!
                        pass
        return shell_is_defined

    def validate_current_text(self):
        self.validate(self.currentText())

    def validate(self, qstr, editing=True):
        """Reimplemented to avoid formatting actions"""
        valid = self.is_valid(qstr)
        if self.hasFocus() and valid is not None:
            if editing and not valid:
                # Combo box text is being modified: invalidate the entry
                self.show_tip(self.tips[valid])
                self.valid.emit(False, False)
            else:
                # A new item has just been selected
                if valid:
                    self.selected()
                    # See spyder-ide/spyder#9542.
                    self.lineEdit().cursorWordForward(False)
                else:
                    self.valid.emit(False, False)


class RichText(QWidget, SpyderWidgetMixin):
    """
    WebView widget with find dialog
    """
    sig_link_clicked = Signal(QUrl)

    def __init__(self, parent):
        if PYQT5 or PYQT6:
            super().__init__(parent, class_parent=parent)
        else:
            QWidget.__init__(self, parent)
            SpyderWidgetMixin.__init__(self, class_parent=parent)

        from qtpy.QtWebEngineWidgets import QWebEnginePage
        from spyder.widgets.browser import FrameWebView

        self.webview = FrameWebView(self)
        self.webview.setup()

        if WEBENGINE:
            self.webview.web_widget.page().setBackgroundColor(
                QColor(MAIN_BG_COLOR))
        else:
            self.webview.web_widget.setStyleSheet(
                "background:{}".format(MAIN_BG_COLOR))
            self.webview.page().setLinkDelegationPolicy(
                QWebEnginePage.DelegateAllLinks)

        self.find_widget = FindReplace(self)
        self.find_widget.set_editor(self.webview.web_widget)
        self.find_widget.hide()

        # Layout
        layout = QVBoxLayout()
        layout.setSpacing(0)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(self.webview)
        layout.addWidget(self.find_widget)
        self.setLayout(layout)

        # Signals
        self.webview.linkClicked.connect(self.sig_link_clicked)

    def set_font(self, font, fixed_font=None):
        """Set font"""
        self.webview.set_font(font, fixed_font=fixed_font)

    def set_html(self, html_text, base_url):
        """Set html text"""
        self.webview.setHtml(html_text, base_url)

    def load_url(self, url):
        if isinstance(url, QUrl):
            qurl = url
        else:
            qurl = QUrl(url)
        self.webview.load(qurl)

    def clear(self):
        self.set_html('', self.webview.url())


class PlainText(QWidget):
    """
    Read-only editor widget with find dialog
    """
    # Signals
    focus_changed = Signal()

    sig_custom_context_menu_requested = Signal(QPoint)

    def __init__(self, parent):
        QWidget.__init__(self, parent)
        self.editor = None

        # Read-only simple code editor
        self.editor = SimpleCodeEditor(self)
        self.editor.setup_editor(
            language='py',
            highlight_current_line=False,
            linenumbers=False,
        )
        self.editor.sig_focus_changed.connect(self.focus_changed)
        self.editor.setReadOnly(True)
        self.editor.setContextMenuPolicy(Qt.CustomContextMenu)

        # Find/replace widget
        self.find_widget = FindReplace(self)
        self.find_widget.set_editor(self.editor)
        self.find_widget.hide()

        layout = QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(self.editor)
        layout.addWidget(self.find_widget)
        self.setLayout(layout)

        self.editor.customContextMenuRequested.connect(
            self.sig_custom_context_menu_requested)

    def set_font(self, font, color_scheme=None):
        """Set font"""
        self.editor.set_color_scheme(color_scheme)
        self.editor.set_font(font)

    def set_color_scheme(self, color_scheme):
        """Set color scheme"""
        self.editor.set_color_scheme(color_scheme)

    def set_text(self, text, is_code):
        if is_code:
            self.editor.set_language('py')
        else:
            self.editor.set_language(None)

        self.editor.set_text(text)
        self.editor.set_cursor_position('sof')

    def clear(self):
        self.editor.clear()

    def set_wrap_mode(self, value):
        self.editor.toggle_wrap_mode(value)

    def copy(self):
        self.editor.copy()

    def select_all(self):
        self.editor.selectAll()


class HelpWidget(PluginMainWidget):

    ENABLE_SPINNER = True

    # Signals
    sig_item_found = Signal()
    """This signal is emitted when an item is found."""

    sig_render_started = Signal()
    """This signal is emitted to inform a help text rendering has started."""

    sig_render_finished = Signal()
    """This signal is emitted to inform a help text rendering has finished."""

    def __init__(self, name=None, plugin=None, parent=None):
        super().__init__(name, plugin, parent)

        # Attributes
        self._starting_up = True
        self._current_color_scheme = None
        self._last_texts = [None, None]
        self._last_editor_doc = None
        self._last_console_cb = None
        self._last_editor_cb = None
        self.css_path = self.get_conf('css_path', CSS_PATH, 'appearance')
        self.no_docs = _("No documentation available")
        self.docstring = True  # TODO: What is this used for?

        # Widgets
        self._sphinx_thread = SphinxThread(
            None,
            html_text_no_doc=warning(self.no_docs, css_path=self.css_path),
            css_path=self.css_path,
        )
        self.shell = None
        self.internal_console = None
        self.internal_shell = None
        self.plain_text = PlainText(self)
        self.rich_text = RichText(self)

        self.source_label = QLabel(_("Source"))
        self.source_label.ID = HelpWidgetToolbarItems.SourceLabel

        self.source_combo = SpyderComboBox(self)
        self.source_combo.ID = HelpWidgetToolbarItems.SourceCombo

        self.object_label = QLabel(_("Object"))
        self.object_label.ID = HelpWidgetToolbarItems.ObjectLabel

        self.object_combo = ObjectComboBox(
            self, HelpWidgetToolbarItems.ObjectCombo)

        self.object_edit = QLineEdit(self)
        self.object_edit.ID = HelpWidgetToolbarItems.ObjectEdit

        # Setup
        self.object_edit.setReadOnly(True)
        self.object_combo.setMaxCount(self.get_conf('max_history_entries'))
        self.object_combo.setItemText(0, '')
        self.plain_text.set_wrap_mode(self.get_conf('wrap'))
        self.source_combo.addItems([_("Console"), _("Editor")])
        if (not programs.is_module_installed('rope') and
                not programs.is_module_installed('jedi', '>=0.11.0')):
            self.source_combo.hide()
            self.source_label.hide()

        # Layout
        self.stacked_widget = QStackedWidget()
        self.stacked_widget.addWidget(self.rich_text)
        self.stacked_widget.addWidget(self.plain_text)

        layout = QVBoxLayout()
        layout.addWidget(self.stacked_widget)
        self.setLayout(layout)

        # Signals
        self._sphinx_thread.html_ready.connect(
            self._on_sphinx_thread_html_ready)
        self._sphinx_thread.error_msg.connect(
            self._on_sphinx_thread_error_msg)
        self.object_combo.valid.connect(self.force_refresh)
        self.rich_text.sig_link_clicked.connect(self.handle_link_clicks)
        self.source_combo.currentIndexChanged.connect(
            lambda x: self.source_changed())
        self.sig_render_started.connect(self.start_spinner)
        self.sig_render_finished.connect(self.stop_spinner)

    # --- PluginMainWidget API
    # ------------------------------------------------------------------------
    def get_title(self):
        return _('Help')

    def setup(self):
        self.wrap_action = self.create_action(
            name=HelpWidgetActions.ToggleWrap,
            text=_("Wrap lines"),
            toggled=True,
            initial=self.get_conf('wrap'),
            option='wrap'
        )
        self.copy_action = self.create_action(
            name=HelpWidgetActions.CopyAction,
            text=_("Copy"),
            triggered=lambda value: self.plain_text.copy(),
            register_shortcut=False,
        )
        self.select_all_action = self.create_action(
            name=HelpWidgetActions.SelectAll,
            text=_("Select All"),
            triggered=lambda value: self.plain_text.select_all(),
            register_shortcut=False,
        )
        self.auto_import_action = self.create_action(
            name=HelpWidgetActions.ToggleAutomaticImport,
            text=_("Automatic import"),
            toggled=True,
            initial=self.get_conf('automatic_import'),
            option='automatic_import'
        )
        self.show_source_action = self.create_action(
            name=HelpWidgetActions.ToggleShowSource,
            text=_("Show Source"),
            toggled=True,
            option='show_source'
        )
        self.rich_text_action = self.create_action(
            name=HelpWidgetActions.ToggleRichMode,
            text=_("Rich Text"),
            toggled=True,
            initial=self.get_conf('rich_mode'),
            option='rich_mode'
        )
        self.plain_text_action = self.create_action(
            name=HelpWidgetActions.TogglePlainMode,
            text=_("Plain Text"),
            toggled=True,
            initial=self.get_conf('plain_mode'),
            option='plain_mode'
        )
        self.locked_action = self.create_action(
            name=HelpWidgetActions.ToggleLocked,
            text=_("Lock/Unlock"),
            toggled=True,
            icon=self.create_icon('lock_open'),
            initial=self.get_conf('locked'),
            option='locked'
        )
        self.home_action = self.create_action(
            name=HelpWidgetActions.Home,
            text=_("Home"),
            triggered=self.show_intro_message,
            icon=self.create_icon('home'),
        )

        # Add the help actions to an exclusive QActionGroup
        help_actions = QActionGroup(self)
        help_actions.setExclusive(True)
        help_actions.addAction(self.plain_text_action)
        help_actions.addAction(self.rich_text_action)

        # Menu
        menu = self.get_options_menu()
        for item in [self.rich_text_action, self.plain_text_action,
                     self.show_source_action]:
            self.add_item_to_menu(
                item,
                menu=menu,
                section=HelpWidgetOptionsMenuSections.Display,
            )

        self.add_item_to_menu(
            self.auto_import_action,
            menu=menu,
            section=HelpWidgetOptionsMenuSections.Other,
        )

        # Plain text menu
        self._plain_text_context_menu = self.create_menu(
            "plain_text_context_menu")
        self.add_item_to_menu(
            self.copy_action,
            self._plain_text_context_menu,
            section="copy_section",
        )
        self.add_item_to_menu(
            self.select_all_action,
            self._plain_text_context_menu,
            section="select_section",
        )
        self.add_item_to_menu(
            self.wrap_action,
            self._plain_text_context_menu,
            section="wrap_section",
        )

        # Toolbar
        toolbar = self.get_main_toolbar()
        for item in [self.source_label, self.source_combo, self.object_label,
                     self.object_combo, self.object_edit, self.home_action,
                     self.locked_action]:
            self.add_item_to_toolbar(
                item,
                toolbar=toolbar,
                section=HelpWidgetMainToolbarSections.Main,
            )

        self.source_changed()
        self.switch_to_rich_text()
        self.show_intro_message()

        # Signals
        self.plain_text.sig_custom_context_menu_requested.connect(
            self._show_plain_text_context_menu)

    def _should_display_welcome_page(self):
        """Determine if the help welcome page should be displayed."""
        return (self._last_editor_doc is None or
                self._last_console_cb is None or
                self._last_editor_cb is None)

    @on_conf_change(option='wrap')
    def on_wrap_option_update(self, value):
        self.plain_text.set_wrap_mode(value)

    @on_conf_change(option='locked')
    def on_lock_update(self, value):
        if value:
            icon = self.create_icon('lock')
            tip = _("Unlock")
        else:
            icon = self.create_icon('lock_open')
            tip = _("Lock")

        action = self.get_action(HelpWidgetActions.ToggleLocked)
        action.setIcon(icon)
        action.setToolTip(tip)

    @on_conf_change(option='automatic_import')
    def on_automatic_import_update(self, value):
        self.object_combo.validate_current_text()
        if self._should_display_welcome_page():
            self.show_intro_message()
        else:
            self.force_refresh()

    @on_conf_change(option='rich_mode')
    def on_rich_mode_update(self, value):
        if value:
            # Plain Text OFF / Rich text ON
            self.docstring = not value
            self.stacked_widget.setCurrentWidget(self.rich_text)
            self.get_action(HelpWidgetActions.ToggleShowSource).setChecked(
                False)
        else:
            # Plain Text ON / Rich text OFF
            self.docstring = value
            self.stacked_widget.setCurrentWidget(self.plain_text)

        if self._should_display_welcome_page():
            self.show_intro_message()
        else:
            self.force_refresh()

    @on_conf_change(option='show_source')
    def on_show_source_update(self, value):
        if value:
            self.switch_to_plain_text()
            self.get_action(HelpWidgetActions.ToggleRichMode).setChecked(
                False)

        self.docstring = not value
        if self._should_display_welcome_page():
            self.show_intro_message()
        else:
            self.force_refresh()

    @on_conf_change(section='appearance', option=['selected', 'ui_theme'])
    def change_color_scheme(self, option, value):
        if option == 'ui_theme':
            value = self.get_conf('selected', section='appearance')

        self.set_plain_text_color_scheme(value)

    def update_actions(self):
        for __, action in self.get_actions().items():
            # IMPORTANT: Since we are defining the main actions in here
            # and the context is WidgetWithChildrenShortcut we need to
            # assign the same actions to the children widgets in order
            # for shortcuts to work
            for widget in [self.plain_text,
                           self.rich_text,
                           self.source_combo,
                           self.object_combo,
                           self.object_edit]:
                if action not in widget.actions():
                    try:
                        widget.addAction(action)
                    except RuntimeError:
                        pass

    def get_focus_widget(self):
        self.object_combo.lineEdit().selectAll()
        return self.object_combo

    # --- Private API
    # ------------------------------------------------------------------------
    @Slot(QPoint)
    def _show_plain_text_context_menu(self, point):
        point = self.plain_text.mapToGlobal(point)
        self._plain_text_context_menu.popup(point)

    def _on_sphinx_thread_html_ready(self, html_text):
        """
        Set our sphinx documentation based on thread result.

        Parameters
        ----------
        html_text: str
            Html results text.
        """
        self._sphinx_thread.wait()
        self.set_rich_text_html(html_text, QUrl.fromLocalFile(self.css_path))
        self.sig_render_finished.emit()
        self.stop_spinner()

    def _on_sphinx_thread_error_msg(self, error_msg):
        """
        Display error message on Sphinx rich text failure.

        Parameters
        ----------
        error_msg: str
            Error message text.
        """
        self._sphinx_thread.wait()
        self.plain_text_action.setChecked(True)
        sphinx_ver = programs.get_module_version('sphinx')
        QMessageBox.critical(
            self,
            _('Help'),
            _("The following error occurred when calling "
              "<b>Sphinx %s</b>. <br>Incompatible Sphinx "
              "version or doc string decoding failed."
              "<br><br>Error message:<br>%s"
              ) % (sphinx_ver, error_msg),
        )
        self.sig_render_finished.emit()

    # --- Public API
    # ------------------------------------------------------------------------
    def source_is_console(self):
        """Return True if source is Console."""
        return self.source_combo.currentIndex() == 0

    def switch_to_editor_source(self):
        """Switch to editor view of the help viewer."""
        self.source_combo.setCurrentIndex(1)

    def switch_to_console_source(self):
        """Switch to console view of the help viewer."""
        self.source_combo.setCurrentIndex(0)

    def source_changed(self):
        """Handle a source (plain/rich) change."""
        is_console = self.source_is_console()
        if is_console:
            self.object_combo.show()
            self.object_edit.hide()
        else:
            # Editor
            self.object_combo.hide()
            self.object_edit.show()

        self.get_action(HelpWidgetActions.ToggleShowSource).setEnabled(
            is_console)
        self.get_action(HelpWidgetActions.ToggleAutomaticImport).setEnabled(
            is_console)
        self.restore_text()

    def save_text(self, callback):
        """
        Save help text.

        Parameters
        ----------
        callback: callable
            Method to call on save.
        """
        if self.source_is_console():
            self._last_console_cb = callback
        else:
            self._last_editor_cb = callback

    def restore_text(self):
        """Restore last text using callback."""
        if self.source_is_console():
            cb = self._last_console_cb
        else:
            cb = self._last_editor_cb

        if cb is None:
            if self.get_conf('plain_mode'):
                self.switch_to_plain_text()
            else:
                self.switch_to_rich_text()
        else:
            func = cb[0]
            args = cb[1:]
            func(*args)
            if func.__self__ is self.rich_text:
                self.switch_to_rich_text()
            else:
                self.switch_to_plain_text()

    @property
    def find_widget(self):
        """Show find widget."""
        if self.get_conf('plain_mode'):
            return self.plain_text.find_widget
        else:
            return self.rich_text.find_widget

    def switch_to_plain_text(self):
        """Switch to plain text mode."""
        self.get_action(HelpWidgetActions.TogglePlainMode).setChecked(True)

    def switch_to_rich_text(self):
        """Switch to rich text mode."""
        self.get_action(HelpWidgetActions.ToggleRichMode).setChecked(True)

    def set_plain_text(self, text, is_code):
        """
        Set plain text docs.

        Parameters
        ----------
        text: str
            Text content.
        is_code: bool
            True if it is code text.

        Notes
        -----
        Text is coming from utils.dochelpers.getdoc
        """
        if type(text) is dict:
            name = text['name']
            if name:
                rst_title = ''.join(['='*len(name), '\n', name, '\n',
                                    '='*len(name), '\n\n'])
            else:
                rst_title = ''
            try:
                if text['argspec']:
                    definition = ''.join(
                        ['Definition: ', name, text['argspec'], '\n\n'])
                else:
                    definition = ''

                if text['note']:
                    note = ''.join(['Type: ', text['note'], '\n\n----\n\n'])
                else:
                    note = ''
            except TypeError:
                definition = self.no_docs
                note = ''

            full_text = ''.join([rst_title, definition, note,
                                 text['docstring']])
        else:
            full_text = text

        self.plain_text.set_text(full_text, is_code)
        self.save_text([self.plain_text.set_text, full_text, is_code])

    def set_rich_text_html(self, html_text, base_url):
        """
        Set rich text.

        Parameters
        ----------
        html_text: str
            Html string.
        base_url: str
            Location of stylesheets and images to load in the page.
        """
        self.rich_text.set_html(html_text, base_url)
        self.save_text([self.rich_text.set_html, html_text, base_url])

    def show_loading_message(self):
        """Create html page to show while the documentation is generated."""
        self.sig_render_started.emit()
        loading_message = _("Retrieving documentation")
        loading_img = get_image_path('loading_sprites')
        if os.name == 'nt':
            loading_img = loading_img.replace('\\', '/')

        self.set_rich_text_html(
            loading(loading_message, loading_img, css_path=self.css_path),
            QUrl.fromLocalFile(self.css_path),
        )

    def show_intro_message(self):
        """Show message on Help with the right shortcuts."""
        intro_message_eq = _(
            "Here you can get help of any object by pressing "
            "%s in front of it, either on the Editor or the "
            "Console.%s")
        intro_message_dif = _(
            "Here you can get help of any object by pressing "
            "%s in front of it on the Editor, or %s in front "
            "of it on the Console.%s")
        intro_message_common = _(
            "Help can also be shown automatically after writing "
            "a left parenthesis next to an object. You can "
            "activate this behavior in %s.")
        prefs = _("Preferences > Help")

        shortcut_editor = self.get_conf(
            'editor/inspect current object', section='shortcuts')
        shortcut_console = self.get_conf(
            'ipython_console/inspect current object', section='shortcuts')

        if sys.platform == 'darwin':
            shortcut_editor = shortcut_editor.replace('Ctrl', 'Cmd')
            shortcut_console = shortcut_console.replace('Ctrl', 'Cmd')

        if self.get_conf('rich_mode'):
            title = _("Usage")
            tutorial_message = _("New to Spyder? Read our")
            tutorial = _("tutorial")
            if shortcut_editor == shortcut_console:
                intro_message = (intro_message_eq + intro_message_common) % (
                    "<b>"+shortcut_editor+"</b>", "<br><br>",
                    "<i>"+prefs+"</i>")
            else:
                intro_message = (intro_message_dif + intro_message_common) % (
                    "<b>"+shortcut_editor+"</b>",
                    "<b>"+shortcut_console+"</b>",
                    "<br><br>", "<i>"+prefs+"</i>")

            self.set_rich_text_html(usage(title, intro_message,
                                          tutorial_message, tutorial,
                                          css_path=self.css_path),
                                    QUrl.fromLocalFile(self.css_path))
        else:
            install_sphinx = "\n\n%s" % _("Please consider installing Sphinx "
                                          "to get documentation rendered in "
                                          "rich text.")
            if shortcut_editor == shortcut_console:
                intro_message = (intro_message_eq + intro_message_common) % (
                    shortcut_editor, "\n\n", prefs)
            else:
                intro_message = (intro_message_dif + intro_message_common) % (
                    shortcut_editor, shortcut_console, "\n\n", prefs)

            intro_message += install_sphinx
            self.set_plain_text(intro_message, is_code=False)

    def show_rich_text(self, text, collapse=False, img_path=''):
        """
        Show text in rich mode.

        Parameters
        ----------
        text: str
            Plain text to display.
        collapse: bool, optional
            Show collapsable sections as collapsed/expanded. Default is False.
        img_path: str, optional
            Path to folder with additional images needed to correctly
            display the rich text help. Default is ''.
        """
        self.switch_to_rich_text()
        context = generate_context(collapse=collapse, img_path=img_path,
                                   css_path=self.css_path)
        self.render_sphinx_doc(text, context)

    def show_plain_text(self, text):
        """
        Show text in plain mode.

        Parameters
        ----------
        text: str
            Plain text to display.
        """
        self.switch_to_plain_text()
        self.set_plain_text(text, is_code=False)

    @Slot()
    def show_tutorial(self):
        """Show the Spyder tutorial."""
        tutorial_path = get_module_source_path('spyder.plugins.help.utils')
        tutorial = os.path.join(tutorial_path, 'tutorial.rst')

        with open(tutorial, 'r') as fh:
            text = fh.read()

        self.show_rich_text(text, collapse=True)

    def handle_link_clicks(self, url):
        """
        Handle how url links should be opened.

        Parameters
        ----------
        url: QUrl
            QUrl object containing the link to open.
        """
        url = to_text_string(url.toString())
        if url == "spy://tutorial":
            self.show_tutorial()
        elif url.startswith('http'):
            start_file(url)
        else:
            self.rich_text.load_url(url)

    @Slot()
    @Slot(bool)
    @Slot(bool, bool)
    def force_refresh(self, valid=True, editing=True):
        """
        Force a refresh/rerender of the help viewer content.

        Parameters
        ----------
        valid: bool, optional
            Default is True.
        editing: bool, optional
            Default is True.
        """
        if valid:
            if self.source_is_console():
                self.set_object_text(None, force_refresh=True)
            elif self._last_editor_doc is not None:
                self.set_editor_doc(self._last_editor_doc, force_refresh=True)

    def set_object_text(self, text, force_refresh=False, ignore_unknown=False):
        """
        Set object's name in Help's combobox.

        Parameters
        ----------
        text: str
            Object name.
        force_refresh: bool, optional
            Force a refresh with the rendering.
        ignore_unknown: bool, optional
            Ignore not found object names.

        See Also
        --------
        :py:meth:spyder.widgets.mixins.GetHelpMixin.show_object_info
        """
        if self.get_conf('locked') and not force_refresh:
            return

        self.switch_to_console_source()
        add_to_combo = True
        if text is None:
            text = to_text_string(self.object_combo.currentText())
            add_to_combo = False

        found = self.show_help(text, ignore_unknown=ignore_unknown)
        if ignore_unknown and not found:
            return

        if add_to_combo:
            self.object_combo.add_text(text)

        if found:
            self.sig_item_found.emit()

        index = self.source_combo.currentIndex()
        self._last_texts[index] = text

    def set_editor_doc(self, help_data, force_refresh=False):
        """
        Set content for help data sent from the editor.

        Parameters
        ----------
        help_data: dict
            Dictionary with editor introspection information.
        force_refresh: bool, optional
            Force a refresh with the rendering.

        Examples
        --------
        >>> help_data = {
            'obj_text': str,
            'name': str,
            'argspec': str,
            'note': str,
            'docstring': str,
            'path': str,
        }
        """
        if self.get_conf('locked') and not force_refresh:
            return

        self.switch_to_editor_source()
        self._last_editor_doc = help_data
        self.object_edit.setText(help_data['obj_text'])

        if self.get_conf('rich_mode'):
            self.render_sphinx_doc(help_data)
        else:
            self.set_plain_text(help_data, is_code=False)

        index = self.source_combo.currentIndex()
        self._last_texts[index] = help_data['docstring']

    def set_shell(self, shell):
        """
        Bind to shell.

        Parameters
        ----------
        shell: object
            internal shell or ipython console shell
        """
        self.shell = shell

    def get_shell(self):
        """
        Return shell which is currently bound to Help.
        """
        if self.shell is None:
            self.shell = self.internal_shell

        return self.shell

    def render_sphinx_doc(self, help_data, context=None, css_path=CSS_PATH):
        """
        Transform help_data dictionary to HTML and show it.

        Parameters
        ----------
        help_data: str or dict
            Dictionary with editor introspection information.
        context: dict
            Sphinx context.
        css_path: str
            Path to CSS file for styling.
        """
        if isinstance(help_data, dict):
            path = help_data.pop('path', '')
            dname = os.path.dirname(path)
        else:
            dname = ''

        # Math rendering option could have changed
        self._sphinx_thread.render(help_data, context, self.get_conf('math'),
                                   dname, css_path=self.css_path)
        self.show_loading_message()

    def show_help(self, obj_text, ignore_unknown=False):
        """
        Show help for an object's name.

        Parameters
        ----------
        obj_text: str
            Object's name.
        ignore_unknown: bool, optional
            Ignore unknown object's name.
        """
        # TODO: This method makes active use of the shells. It would be better
        # to use signals and pass information this way for better decoupling.
        shell = self.get_shell()
        if shell is None:
            return

        obj_text = to_text_string(obj_text)

        if not shell.is_defined(obj_text):
            if (self.get_conf('automatic_import')
                    and self.internal_shell.is_defined(obj_text,
                                                       force_import=True)):
                shell = self.internal_shell
            else:
                shell = None
                doc = None
                source_text = None

        if shell is not None:
            doc = shell.get_doc(obj_text)
            source_text = shell.get_source(obj_text)

        is_code = False

        if self.get_conf('rich_mode'):
            self.render_sphinx_doc(doc, css_path=self.css_path)
            return doc is not None
        elif self.docstring:
            hlp_text = doc
            if hlp_text is None:
                hlp_text = source_text
                if hlp_text is None:
                    return False
        else:
            hlp_text = source_text
            if hlp_text is None:
                hlp_text = doc
                if hlp_text is None:
                    hlp_text = _("No source code available.")
                    if ignore_unknown:
                        return False
            else:
                is_code = True

        self.set_plain_text(hlp_text, is_code=is_code)
        return True

    def set_rich_text_font(self, font, fixed_font):
        """
        Set rich text mode font.

        Parameters
        ----------
        fixed_font: QFont
            The current rich text font to use.
        """
        self.rich_text.set_font(font, fixed_font=fixed_font)

    def set_plain_text_font(self, font, color_scheme=None):
        """
        Set plain text mode font.

        Parameters
        ----------
        font: QFont
            The current plain text font to use.
        color_scheme: str
            The selected color scheme.
        """
        if color_scheme is None:
            color_scheme = self._current_color_scheme

        self.plain_text.set_font(font, color_scheme=color_scheme)

    def set_plain_text_color_scheme(self, color_scheme):
        """
        Set plain text mode color scheme.

        Parameters
        ----------
        color_scheme: str
            The selected color scheme.
        """
        self._current_color_scheme = color_scheme
        self.plain_text.set_color_scheme(color_scheme)

    def set_history(self, history):
        """
        Set list of strings on object combo box.

        Parameters
        ----------
        history: list
            List of strings of objects.
        """
        self.object_combo.addItems(history)

    def get_history(self):
        """
        Return list of strings on object combo box.
        """
        history = []
        for index in range(self.object_combo.count()):
            history.append(to_text_string(self.object_combo.itemText(index)))

        return history

    def set_internal_console(self, console):
        """
        Set the internal console shell.

        Parameters
        ----------
        console: :py:class:spyder.plugins.console.plugin.Console
            Console plugin.
        """
        self.internal_console = console
        if self.internal_console is not None:
            self.internal_shell = console.get_widget().shell
