diff --git a/doc/WhatsNew.rst b/doc/WhatsNew.rst index 852a83af6..f9175af41 100644 --- a/doc/WhatsNew.rst +++ b/doc/WhatsNew.rst @@ -29,6 +29,7 @@ Ver 5.0.0 (unreleased) - Added color distribution ("stretch") control to Info plugin - Added LoaderConfig plugin; allows setting of loader priorities for various MIME types +- Fix a number of issues with the help system to make it more robust Ver 4.1.0 (2022-06-30) ====================== diff --git a/ginga/GingaPlugin.py b/ginga/GingaPlugin.py index c90fc41d0..787626953 100644 --- a/ginga/GingaPlugin.py +++ b/ginga/GingaPlugin.py @@ -52,22 +52,12 @@ def _help_docstring(self): plg_mod = inspect.getmodule(self) plg_doc = ('{}\n{}\n'.format(plg_name, '=' * len(plg_name)) + plg_mod.__doc__) + return plg_name, plg_doc - self.fv.help_text(plg_name, plg_doc, text_kind='rst', trim_pfx=4) - - def help(self): + def help(self, text_kind='rst'): """Display help for the plugin.""" - if not self.fv.gpmon.has_plugin('WBrowser'): - self._help_docstring() - return - - self.fv.start_global_plugin('WBrowser') - - # need to let GUI finish processing, it seems - self.fv.update_pending() - - obj = self.fv.gpmon.get_plugin('WBrowser') - obj.show_help(plugin=self, no_url_callback=self._help_docstring) + plg_name, plg_doc = self._help_docstring() + self.fv.help_plugin(self, plg_name, plg_doc, text_kind=text_kind) class GlobalPlugin(BasePlugin): diff --git a/ginga/doc/download_doc.py b/ginga/doc/download_doc.py index 35ae82ebf..4753d5475 100644 --- a/ginga/doc/download_doc.py +++ b/ginga/doc/download_doc.py @@ -1,5 +1,6 @@ -"""Download rendered HTML doc from RTD.""" +"""Tools for accessing or downloading HTML doc from RTD.""" import os +import re import shutil import zipfile import urllib @@ -7,16 +8,17 @@ from astropy.utils import minversion from astropy.utils.data import get_pkg_data_path +from ginga.GingaPlugin import GlobalPlugin, LocalPlugin from ginga import toolkit +import ginga -__all__ = ['get_doc'] +__all__ = ['get_doc', 'get_online_docs_url'] def _find_rtd_version(): """Find closest RTD doc version.""" vstr = 'latest' try: - import ginga from bs4 import BeautifulSoup except ImportError: return vstr @@ -138,8 +140,6 @@ def get_doc(logger=None, plugin=None, reporthook=None): URL to local documentation, if available. """ - from ginga.GingaPlugin import GlobalPlugin, LocalPlugin - if isinstance(plugin, GlobalPlugin): plugin_page = 'plugins_global' plugin_name = str(plugin) @@ -153,20 +153,13 @@ def get_doc(logger=None, plugin=None, reporthook=None): try: index_html = _download_rtd_zip(reporthook=reporthook) - # Download failed, use online resource + # Download failed except Exception as e: - url = 'https://ginga.readthedocs.io/en/latest/' - - if plugin_name is not None: - if toolkit.family.startswith('qt'): - # This displays plugin docstring. - url = None - else: - # This redirects to online doc. - url += 'manual/{}/{}.html'.format(plugin_page, plugin_name) - if logger is not None: - logger.error(str(e)) + logger.error(f"failed to download documentation: {e}", + exc_info=True) + # fall back to online version + url = get_online_docs_url(plugin=plugin) # Use local resource else: @@ -178,3 +171,37 @@ def get_doc(logger=None, plugin=None, reporthook=None): url += '#{}'.format(plugin_name) return url + + +def get_online_docs_url(plugin=None): + """ + Return URL to online documentation closest to this Ginga version. + + Parameters + ---------- + plugin : obj or `None` + Plugin object. If given, URL points to plugin doc directly. + If this function is called from within plugin class, + pass ``self`` here. + + Returns + ------- + url : str + URL to online documentation (possibly top-level). + + """ + ginga_ver = ginga.__version__ + if re.match(r'^v\d+\.\d+\.\d+$', ginga_ver): + rtd_version = ginga_ver + else: + # default to latest + rtd_version = 'latest' + url = f"https://ginga.readthedocs.io/en/{rtd_version}" + if plugin is not None: + plugin_name = str(plugin) + if isinstance(plugin, GlobalPlugin): + url += f'/manual/plugins_global/{plugin_name}.html' + elif isinstance(plugin, LocalPlugin): + url += f'/manual/plugins_local/{plugin_name}.html' + + return url diff --git a/ginga/gtk3w/Widgets.py b/ginga/gtk3w/Widgets.py index 4c0f0dd33..d0ec76dbc 100644 --- a/ginga/gtk3w/Widgets.py +++ b/ginga/gtk3w/Widgets.py @@ -2457,18 +2457,28 @@ class Dialog(TopLevelMixin, WidgetBase): def __init__(self, title='', flags=0, buttons=[], parent=None, modal=False): WidgetBase.__init__(self) + self.buttons = [] if parent is not None: self.parent = parent.get_widget() else: self.parent = None - button_list = [] + self.widget = Gtk.Dialog(title=title, flags=flags) + btn_box = Gtk.ButtonBox() + btn_box.set_border_width(5) + btn_box.set_spacing(4) + for name, val in buttons: - button_list.extend([name, val]) + btn = Button(name) + self.buttons.append(btn) + + def cb(val): + return lambda w: self._cb_redirect(val) + + btn.add_callback('activated', cb(val)) + btn_box.pack_start(btn.get_widget(), True, True, 0) - self.widget = Gtk.Dialog(title=title, flags=flags, - buttons=tuple(button_list)) self.widget.set_modal(modal) TopLevelMixin.__init__(self, title=title) @@ -2477,12 +2487,13 @@ def __init__(self, title='', flags=0, buttons=[], self.content.set_border_width(0) content = self.widget.get_content_area() content.pack_start(self.content.get_widget(), True, True, 0) + content.pack_end(btn_box, True, True, 0) - self.widget.connect("response", self._cb_redirect) + #self.widget.connect("response", self._cb_redirect) self.enable_callback('activated') - def _cb_redirect(self, w, val): + def _cb_redirect(self, val): self.make_callback('activated', val) def get_content_area(self): diff --git a/ginga/qtw/QtHelp.py b/ginga/qtw/QtHelp.py index c8f6044a7..487db1244 100644 --- a/ginga/qtw/QtHelp.py +++ b/ginga/qtw/QtHelp.py @@ -524,7 +524,7 @@ def set_default_opengl_context(): fmt.setVersion(req.major, req.minor) fmt.setProfile(QSurfaceFormat.CoreProfile) fmt.setDefaultFormat(fmt) - QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts) + QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts, True) def delete_widget(w): diff --git a/ginga/qtw/Widgets.py b/ginga/qtw/Widgets.py index 21a9f9c90..ba54bcb8f 100644 --- a/ginga/qtw/Widgets.py +++ b/ginga/qtw/Widgets.py @@ -2197,6 +2197,7 @@ def __init__(self, title='', flags=None, buttons=[], parent = parent.get_widget() self.widget = QtGui.QDialog(parent) self.widget.setModal(modal) + self.buttons = [] vbox = QtGui.QVBoxLayout() vbox.setContentsMargins(0, 0, 0, 0) @@ -2210,16 +2211,19 @@ def __init__(self, title='', flags=None, buttons=[], if len(buttons) > 0: hbox_w = QtGui.QWidget() hbox = QtGui.QHBoxLayout() + hbox.setContentsMargins(5, 5, 5, 5) + hbox.setSpacing(4) hbox_w.setLayout(hbox) for name, val in buttons: - btn = QtGui.QPushButton(name) + btn = Button(name) + self.buttons.append(btn) def cb(val): - return lambda: self._cb_redirect(val) + return lambda w: self._cb_redirect(val) - btn.clicked.connect(cb(val)) - hbox.addWidget(btn, stretch=0) + btn.add_callback('activated', cb(val)) + hbox.addWidget(btn.get_widget(), stretch=1) vbox.addWidget(hbox_w, stretch=0) # self.widget.closeEvent = lambda event: self.delete() diff --git a/ginga/rv/Control.py b/ginga/rv/Control.py index 6abae1be9..0ba40ba02 100644 --- a/ginga/rv/Control.py +++ b/ginga/rv/Control.py @@ -39,13 +39,6 @@ from ginga.rv.Channel import Channel from ginga.rv.rvmode import RVMode -have_docutils = False -try: - from docutils.core import publish_string - have_docutils = True -except ImportError: - pass - pluginconfpfx = None @@ -430,10 +423,10 @@ def help_text(self, name, text, text_kind='plain', trim_pfx=0): """ Provide help text for the user. - This method will convert the text as necessary with docutils and - display it in the WBrowser plugin, if available. If the plugin is - not available and the text is type 'rst' then the text will be - displayed in a plain text widget. + This method will trim the text as necessary and display it in + the WBrowser plugin, if available. If the plugin is not + available and the text is type 'rst' or 'plain' then the text + will be displayed in a plain text widget. Parameters ---------- @@ -456,33 +449,16 @@ def help_text(self, name, text, text_kind='plain', trim_pfx=0): # of each line text = toolbox.trim_prefix(text, trim_pfx) - if text_kind == 'rst': - # try to convert RST to HTML using docutils - try: - overrides = {'input_encoding': 'ascii', - 'output_encoding': 'utf-8'} - text_html = publish_string(text, writer_name='html', - settings_overrides=overrides) - # docutils produces 'bytes' output, but webkit needs - # a utf-8 string - text = text_html.decode('utf-8') - text_kind = 'html' + if text_kind in ['rst', 'plain']: + self.show_help_text(name, text) - except Exception as e: - self.logger.error("Error converting help text to HTML: %s" % ( - str(e))) - # revert to showing RST as plain text + elif text_kind == 'html': + self.help(text=text, text_kind='html') else: raise ValueError( "I don't know how to display text of kind '%s'" % (text_kind)) - if text_kind == 'html': - self.help(text=text, text_kind='html') - - else: - self.show_help_text(name, text) - def help(self, text=None, text_kind='url'): if not self.gpmon.has_plugin('WBrowser'): @@ -503,7 +479,29 @@ def help(self, text=None, text_kind='url'): else: obj.show_help() - def show_help_text(self, name, help_txt, wsname='right'): + def help_plugin(self, plugin_obj, plugin_name, plugin_doc, text_kind='rst'): + """ + Called from a plugin's default help() method. Attempts to display + the plugin documentation in the WBrowser plugin (preferably) or + falling back to showing plain text in a text widget. + """ + if not self.gpmon.has_plugin('WBrowser'): + # text kind is assumed to not be a URL, but 'rst' or 'plain' + self.show_help_text(plugin_name, plugin_doc) + return + + self.start_global_plugin('WBrowser') + + # need to let GUI finish processing, it seems + self.update_pending() + + def _fallback(): + self.show_help_text(plugin_name, plugin_doc) + + obj = self.gpmon.get_plugin('WBrowser') + obj.show_help(plugin=plugin_obj, no_url_callback=_fallback) + + def show_help_text(self, name, help_txt, wsname='channels'): """ Show help text in a closeable tab window. The title of the window is set from ``name`` prefixed with 'HELP:' diff --git a/ginga/rv/plugins/WBrowser.py b/ginga/rv/plugins/WBrowser.py index a35fc4290..f6d4012d1 100644 --- a/ginga/rv/plugins/WBrowser.py +++ b/ginga/rv/plugins/WBrowser.py @@ -16,14 +16,18 @@ from *ReadTheDocs* for the matching version. If successful, plugin documentation from that download is displayed. If not successful or deliberately disabled in "plugin_WBrowser.cfg", -Ginga will render the plugin's docstring locally. - +Ginga will offer choices as to how to render the plugin's docstring: +either showing the RST text in a plain text widget or attempting +to view the documentation from the RTD web site using an external +browser. """ -import os +import os.path +import webbrowser # GINGA from ginga.GingaPlugin import GlobalPlugin +from ginga.doc import download_doc from ginga.gw import Widgets __all__ = ['WBrowser'] @@ -37,6 +41,14 @@ """ +WAIT_PLAIN = """ +Opening documentation from ReadTheDocs in external web browser using +Python `webbrowser` module ... + +If this fails, please visit: + %(url)s +""" + class WBrowser(GlobalPlugin): @@ -49,37 +61,80 @@ def __init__(self, fv): self.settings.add_defaults(offline_doc_only=False) self.settings.load(onError='silent') + self.browser = None + self._do_remember = False + self._no_browser_choice = 0 + self._plugin = None + self.gui_up = False + def build_gui(self, container): - if not Widgets.has_webkit: - self.browser = Widgets.Label( - "Please install the python-webkit package to enable " - "this plugin") + vbox = Widgets.VBox() + self.browser = None + self.w.tw = None + + if Widgets.has_webkit: + try: + self.browser = Widgets.WebView() + except Exception as e: + self.logger.error(f"can't create browser widget: {e}", + exc_info=True) + + # Create troubleshooting dialog if downloading cannot be done + self.w.dialog = Widgets.Dialog(title="Problem loading documentation", + parent=container, + modal=False, + buttons=[("Cancel", 0), + ("Show RST text", 1), + ("Use external browser", 2), + ]) + self.w.dialog.buttons[0].set_tooltip("Skip help") + self.w.dialog.buttons[1].set_tooltip("Show local docstring for plugin help") + self.w.dialog.buttons[2].set_tooltip("Show online web documentation in external browser") + vbox2 = self.w.dialog.get_content_area() + self.w.error_text = Widgets.TextArea(wrap=True, editable=False) + vbox2.add_widget(self.w.error_text, stretch=1) + cb = Widgets.CheckBox("Remember my choice for session") + cb.set_state(False) + vbox2.add_widget(cb, stretch=0) + self.w.cb_remember = cb + cb.add_callback('activated', self._remember_cb) + self.w.dialog.add_callback('activated', self._handle_alternate_cb) + + if self.browser is None: + self.w.error_text.set_text("The built-in browser could not " + "be created.\nHere are your options:") + msg_font = self.fv.get_font('fixed', 12) + self.w.tw = Widgets.TextArea(wrap=False, editable=False) + self.w.tw.set_font(msg_font) + vbox.add_widget(self.w.tw, stretch=1) + self.w.tw.set_text("Please install the pyqtwebengine (Qt) or \n" + "webkit2 (Gtk) package to fully enable \n" + "this plugin\n") + else: - self.browser = Widgets.WebView() - - sw = Widgets.ScrollArea() - sw.set_widget(self.browser) - - container.add_widget(sw, stretch=1) - sw.show() - - self.entry = Widgets.TextEntrySet() - container.add_widget(self.entry, stretch=0) - self.entry.add_callback('activated', lambda w: self.browse_cb()) - - tbar = Widgets.Toolbar(orientation='horizontal') - for tt, cb, ico in ( - ('Go back', lambda w: self.back_cb(), 'prev_48.png'), - ('Go forward', lambda w: self.forward_cb(), 'next_48.png'), - ('Reload page', lambda w: self.reload_cb(), 'rotate_48.png'), - ('Stop loading', lambda w: self.stop_cb(), 'stop_48.png'), - ('Go to top of documentation', lambda w: self.show_help(), - 'fits.png')): - btn = tbar.add_action( - None, iconpath=os.path.join(self.fv.iconpath, ico)) - btn.add_callback('activated', cb) - btn.set_tooltip(tt) - container.add_widget(tbar, stretch=0) + sw = Widgets.ScrollArea() + sw.set_widget(self.browser) + + vbox.add_widget(sw, stretch=1) + + self.w.entry = Widgets.TextEntrySet() + vbox.add_widget(self.w.entry, stretch=0) + self.w.entry.add_callback('activated', lambda w: self.browse_cb()) + + tbar = Widgets.Toolbar(orientation='horizontal') + for tt, cb, ico in ( + ('Go back', lambda w: self.back_cb(), 'prev.svg'), + ('Go forward', lambda w: self.forward_cb(), 'next.svg'), + ('Reload page', lambda w: self.reload_cb(), 'rotate.svg'), + ('Stop loading', lambda w: self.stop_cb(), 'stop.svg'), + ('Go to top of documentation', lambda w: self.show_help(), + 'file.svg')): + btn = tbar.add_action( + None, iconpath=os.path.join(self.fv.iconpath, ico)) + btn.add_callback('activated', cb) + btn.set_tooltip(tt) + + vbox.add_widget(tbar, stretch=0) btns = Widgets.HBox() btns.set_border_width(4) @@ -91,12 +146,22 @@ def build_gui(self, container): btn.add_callback('activated', lambda w: self.help()) btns.add_widget(btn, stretch=0) btns.add_widget(Widgets.Label(''), stretch=1) - container.add_widget(btns, stretch=0) + vbox.add_widget(btns, stretch=0) + + container.add_widget(vbox, stretch=1) self.gui_up = True + def has_browser(self): + return self.browser is not None + + def _download_error(self, errmsg): + # called when failed to download in _download_doc() + self.fv.assert_gui_thread() + self.w.error_text.set_text(errmsg) + self.w.dialog.show() + def _download_doc(self, plugin=None, no_url_callback=None): - from ginga.doc.download_doc import get_doc self.fv.assert_nongui_thread() self.fv.gui_do(self._load_doc, WAIT_HTML, url_is_content=True) @@ -104,21 +169,28 @@ def _download_doc(self, plugin=None, no_url_callback=None): def _dl_indicator(count, blksize, totalsize): pct = (count * blksize) / totalsize msg = 'Downloading: {:.1%} complete'.format(pct) - self.fv.gui_do(self.entry.set_text, msg) + self.fv.gui_do(self.w.entry.set_text, msg) # This can block as long as it takes without blocking the UI. if self.settings.get('offline_doc_only', False): url = None # DEBUG: Use this to force offline mode. else: - url = get_doc(logger=self.logger, plugin=plugin, - reporthook=_dl_indicator) - - self.fv.gui_do(self._load_doc, url, no_url_callback=no_url_callback) + try: + url = download_doc.get_doc(logger=self.logger, plugin=plugin, + reporthook=_dl_indicator) + self.fv.gui_do(self._load_doc, url, + no_url_callback=no_url_callback) + + except Exception as e: + # we can get HTTP 403 ("Forbidden") errors and so forth + errmsg = f"Error downloading documentation: {e}" + self.logger.error(errmsg, exc_info=True) + self.fv.gui_do(self._download_error, errmsg) def _load_doc(self, url, no_url_callback=None, url_is_content=False): self.fv.assert_gui_thread() if url is None: - self.entry.set_text('') + self.w.entry.set_text('') if no_url_callback is None: self.fv.show_error("Couldn't load web page") else: @@ -128,31 +200,62 @@ def _load_doc(self, url, no_url_callback=None, url_is_content=False): def show_help(self, plugin=None, no_url_callback=None): """See `~ginga.GingaPlugin` for usage of optional keywords.""" - if not Widgets.has_webkit: + # record plugin choice + self._plugin = plugin + + if not self.has_browser(): + if self._no_browser_choice == 0: + self.w.dialog.show() + else: + self._handle_alternate_cb(self.w.dialog, self._no_browser_choice) return self.fv.nongui_do(self._download_doc, plugin=plugin, no_url_callback=no_url_callback) + def _display_externally(self, url): + self.w.tw.append_text("\n---------\n") + self.w.tw.append_text(WAIT_PLAIN % dict(url=url)) + webbrowser.open(url) + + def _handle_alternate_cb(self, dialog_w, val): + dialog_w.hide() + if self._do_remember: + self._no_browser_choice = val + plugin = self._plugin + if val == 1: + if plugin is not None: + name, doc = plugin._help_docstring() + self.fv.show_help_text(name, doc) + self.close() + elif val == 2: + url = download_doc.get_online_docs_url(plugin=plugin) + self._display_externally(url) + self.close() + + def _remember_cb(self, cb_w, tf): + self._do_remember = tf + def browse(self, url, url_is_content=False): - if not Widgets.has_webkit: + if not self.has_browser(): + self._display_externally(url, not url_is_content) return try: if url_is_content: # This was load_html() self.browser.load_html_string(url) - self.entry.set_text('') + self.w.entry.set_text('') else: self.logger.debug("Browsing '{}'".format(url)) self.browser.load_url(url) - self.entry.set_text(url) + self.w.entry.set_text(url) except Exception as e: self.fv.show_error("Couldn't load web page: {}".format(str(e))) else: self.browser.show() def browse_cb(self): - url = str(self.entry.get_text()).strip() + url = str(self.w.entry.get_text()).strip() if len(url) > 0: self.browse(url) @@ -176,6 +279,10 @@ def stop_cb(self): return self.browser.stop_loading() + def stop(self): + self.browser = None + self.gui_up = False + def close(self): self.fv.stop_global_plugin(str(self)) return True diff --git a/ginga/web/pgw/Widgets.py b/ginga/web/pgw/Widgets.py index 4efd8e2b1..93b9128e2 100644 --- a/ginga/web/pgw/Widgets.py +++ b/ginga/web/pgw/Widgets.py @@ -3589,9 +3589,9 @@ def __init__(self, title='', flags=None, buttons=[], self.title = title self.parent = parent - self.buttons = buttons self.value = None self.modal = modal + self.buttons = [] self.body = VBox() for name in ('activated', 'open', 'close', 'resize'): self.enable_callback(name) @@ -3607,6 +3607,7 @@ def __init__(self, title='', flags=None, buttons=[], hbox.set_spacing(4) for name, val in buttons: btn = Button(name) + self.buttons.append(btn) btn.add_callback('activated', self._btn_choice, name, val) hbox.add_widget(btn) self.body.add_widget(hbox, stretch=0)