From bd852a0a77256695c8caabbce14ebad5af627beb Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 29 Sep 2022 15:02:18 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Replace=20sphinx-togg?= =?UTF-8?q?lebutton=20with=20built-in=20functionality=20(#446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows for tighter integration with myst-nb: - Nicer rendering of the hidden content buttons - Customisation of the hide/show prompts The implementation uses "static" details/summary HTML tags directly + CSS, similar to [sphinx-design](https://github.com/executablebooks/sphinx-design), rather than the JS implementation used by sphinx-togglebutton. --- docs/render/hiding.md | 28 +++- myst_nb/core/config.py | 29 ++++ myst_nb/core/render.py | 40 ++++-- myst_nb/sphinx_.py | 78 +++++++++++ myst_nb/sphinx_ext.py | 31 ++--- myst_nb/static/mystnb.css | 59 +++++++- pyproject.toml | 10 +- tests/notebooks/hide_cell_content.ipynb | 126 ++++++++++++++++++ tests/test_render_outputs.py | 9 ++ .../test_hide_cell_content.xml | 36 +++++ tox.ini | 6 +- 11 files changed, 414 insertions(+), 38 deletions(-) create mode 100644 tests/notebooks/hide_cell_content.ipynb create mode 100644 tests/test_render_outputs/test_hide_cell_content.xml diff --git a/docs/render/hiding.md b/docs/render/hiding.md index cf6d18e5..f12cae0b 100644 --- a/docs/render/hiding.md +++ b/docs/render/hiding.md @@ -7,8 +7,7 @@ kernelspec: # Hide cell contents You can use Jupyter Notebook **cell tags** to control some of the behavior of -the rendered notebook. This uses the [**`sphinx-togglebutton`**](https://sphinx-togglebutton.readthedocs.io/en/latest/) -package to add a little button that toggles the visibility of content.[^download] +the rendered notebook.[^download] [^download]: This notebook can be downloaded as **{nb-download}`hiding.ipynb`** and {download}`hiding.md` @@ -66,6 +65,31 @@ fig, ax = plt.subplots() points =ax.scatter(*data, c=data[0], s=data[0]) ``` +You can control the hide/show prompts by using the `code_prompt_show` and `code_prompt_hide` configuration options. +`{type}` will be replaced with `content`, `source`, or `outputs`, depending on the hide tag. +See the {ref}`config/intro` section for more details. + +````markdown + +```{code-cell} ipython3 +:tags: [hide-cell] +:mystnb: +: code_prompt_show: "My show prompt" +: code_prompt_hide: "My hide prompt" + +print("hallo world") +``` +```` + +```{code-cell} ipython3 +:tags: [hide-cell] +:mystnb: +: code_prompt_show: "My show prompt for {type}" +: code_prompt_hide: "My hide prompt for {type}" + +print("hallo world") +``` + (use/hiding/markdown)= ## Hide markdown cells diff --git a/myst_nb/core/config.py b/myst_nb/core/config.py index 62c162f6..d90e85ab 100644 --- a/myst_nb/core/config.py +++ b/myst_nb/core/config.py @@ -323,6 +323,35 @@ def __post_init__(self): ), }, ) + + code_prompt_show: str = dc.field( + default="Show code cell {type}", + metadata={ + "validator": instance_of(str), + "help": "Prompt to expand hidden code cell {content|source|outputs}", + "sections": ( + Section.global_lvl, + Section.file_lvl, + Section.cell_lvl, + Section.render, + ), + }, + ) + + code_prompt_hide: str = dc.field( + default="Hide code cell {type}", + metadata={ + "validator": instance_of(str), + "help": "Prompt to collapse hidden code cell {content|source|outputs}", + "sections": ( + Section.global_lvl, + Section.file_lvl, + Section.cell_lvl, + Section.render, + ), + }, + ) + number_source_lines: bool = dc.field( default=False, metadata={ diff --git a/myst_nb/core/render.py b/myst_nb/core/render.py index 8f697065..108a349c 100644 --- a/myst_nb/core/render.py +++ b/myst_nb/core/render.py @@ -128,38 +128,49 @@ def render_nb_cell_raw(self: SelfType, token: SyntaxTreeNode) -> None: def render_nb_cell_code(self: SelfType, token: SyntaxTreeNode) -> None: """Render a notebook code cell.""" cell_index = token.meta["index"] + cell_line = token_line(token, 0) or None tags = token.meta["metadata"].get("tags", []) exec_count, outputs = self._get_nb_code_cell_outputs(token) + classes = ["cell"] + for tag in tags: + classes.append(f"tag_{tag.replace(' ', '_')}") + # TODO do we need this -/_ duplication of tag names, or can we deprecate one? + hide_cell = "hide-cell" in tags remove_input = ( self.get_cell_level_config( - "remove_code_source", - token.meta["metadata"], - line=token_line(token, 0) or None, + "remove_code_source", token.meta["metadata"], line=cell_line ) or ("remove_input" in tags) or ("remove-input" in tags) ) + hide_input = "hide-input" in tags remove_output = ( self.get_cell_level_config( - "remove_code_outputs", - token.meta["metadata"], - line=token_line(token, 0) or None, + "remove_code_outputs", token.meta["metadata"], line=cell_line ) or ("remove_output" in tags) or ("remove-output" in tags) ) + hide_output = "hide-output" in tags # if we are remove both the input and output, we can skip the cell if remove_input and remove_output: return + hide_mode = None + if hide_cell: + hide_mode = "all" + elif hide_input and hide_output: + hide_mode = "all" + elif hide_input: + hide_mode = "input" + elif hide_output: + hide_mode = "output" + # create a container for all the input/output - classes = ["cell"] - for tag in tags: - classes.append(f"tag_{tag.replace(' ', '_')}") cell_container = nodes.container( nb_element="cell_code", cell_index=cell_index, @@ -168,6 +179,17 @@ def render_nb_cell_code(self: SelfType, token: SyntaxTreeNode) -> None: cell_metadata=token.meta["metadata"], classes=classes, ) + if hide_mode: + cell_container["hide_mode"] = hide_mode + code_prompt_show = self.get_cell_level_config( + "code_prompt_show", token.meta["metadata"], line=cell_line + ) + code_prompt_hide = self.get_cell_level_config( + "code_prompt_hide", token.meta["metadata"], line=cell_line + ) + cell_container["prompt_show"] = code_prompt_show + cell_container["prompt_hide"] = code_prompt_hide + self.add_line_and_source_path(cell_container, token) with self.current_node_context(cell_container, append=True): diff --git a/myst_nb/sphinx_.py b/myst_nb/sphinx_.py index 4a3a57a0..ccdfc9e4 100644 --- a/myst_nb/sphinx_.py +++ b/myst_nb/sphinx_.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections import defaultdict +from html import escape import json from pathlib import Path import re @@ -21,6 +22,7 @@ from sphinx.environment.collectors import EnvironmentCollector from sphinx.transforms.post_transforms import SphinxPostTransform from sphinx.util import logging as sphinx_logging +from sphinx.util.docutils import SphinxTranslator from myst_nb._compat import findall from myst_nb.core.config import NbParserConfig @@ -472,3 +474,79 @@ def default(self, obj): if isinstance(obj, bytes): return obj.decode("ascii") return json.JSONEncoder.default(self, obj) + + +class HideCodeCellNode(nodes.Element): + """Node for hiding cell input.""" + + @classmethod + def add_to_app(cls, app: Sphinx): + app.add_node(cls, html=(visit_HideCellInput, depart_HideCellInput)) + + +def visit_HideCellInput(self: SphinxTranslator, node: HideCodeCellNode): + classes = " ".join(node["classes"]) + self.body.append(f'
\n') + self.body.append('\n') + self.body.append(f'\n') + self.body.append(f'{escape(node["prompt_hide"])}\n') + self.body.append("\n") + + +def depart_HideCellInput(self: SphinxTranslator, node: HideCodeCellNode): + self.body.append("
\n") + + +class HideInputCells(SphinxPostTransform): + """Hide input cells in the HTML output.""" + + default_priority = 199 + formats = ("html",) + + def run(self, **kwargs): + + for node in findall(self.document)(nodes.container): + + if ( + node.get("nb_element") == "cell_code" + and node.get("hide_mode") + and node.children + ): + hide_mode = node.get("hide_mode") + has_input = node.children[0].get("nb_element") == "cell_code_source" + has_output = node.children[-1].get("nb_element") == "cell_code_output" + + # if we have the code source (input) element, + # and we are collapsing the input or input+output + # then we attach the "collapse button" above the input + if has_input and hide_mode == "input": + wrap_node = HideCodeCellNode( + prompt_show=node["prompt_show"].replace("{type}", "source"), + prompt_hide=node["prompt_hide"].replace("{type}", "source"), + ) + wrap_node["classes"].append("above-input") + code = node.children[0] + wrap_node.append(code) + node.replace(code, wrap_node) + + elif has_input and hide_mode == "all": + wrap_node = HideCodeCellNode( + prompt_show=node["prompt_show"].replace("{type}", "content"), + prompt_hide=node["prompt_hide"].replace("{type}", "content"), + ) + wrap_node["classes"].append("above-input") + wrap_node.extend(node.children) + node.children = [wrap_node] + + # if we don't have the code source (input) element, + # or are only hiding the output, + # then we place the "collapse button" above the output + elif has_output and hide_mode in ("output", "all"): + wrap_node = HideCodeCellNode( + prompt_show=node["prompt_show"].replace("{type}", "outputs"), + prompt_hide=node["prompt_hide"].replace("{type}", "outputs"), + ) + wrap_node["classes"].append("above-output") + output = node.children[-1] + wrap_node.append(output) + node.replace(output, wrap_node) diff --git a/myst_nb/sphinx_ext.py b/myst_nb/sphinx_ext.py index e339e177..f19f2171 100644 --- a/myst_nb/sphinx_ext.py +++ b/myst_nb/sphinx_ext.py @@ -19,7 +19,13 @@ from myst_nb.ext.eval import load_eval_sphinx from myst_nb.ext.glue import load_glue_sphinx from myst_nb.ext.glue.crossref import ReplacePendingGlueReferences -from myst_nb.sphinx_ import NbMetadataCollector, Parser, SelectMimeType +from myst_nb.sphinx_ import ( + HideCodeCellNode, + HideInputCells, + NbMetadataCollector, + Parser, + SelectMimeType, +) SPHINX_LOGGER = sphinx_logging.getLogger(__name__) OUTPUT_FOLDER = "jupyter_execute" @@ -83,17 +89,16 @@ def sphinx_setup(app: Sphinx): app.add_post_transform(SelectMimeType) app.add_post_transform(ReplacePendingGlueReferences) + # setup collapsible content + app.add_post_transform(HideInputCells) + HideCodeCellNode.add_to_app(app) + # add HTML resources app.add_css_file("mystnb.css") app.connect("build-finished", add_global_html_resources) # note, this event is only available in Sphinx >= 3.5 app.connect("html-page-context", add_per_page_html_resources) - # add configuration for hiding cell input/output - # TODO replace this, or make it optional - app.setup_extension("sphinx_togglebutton") - app.connect("config-inited", update_togglebutton_classes) - # Note lexers are registered as `pygments.lexers` entry-points # and so do not need to be added here. @@ -191,17 +196,3 @@ def add_per_page_html_resources( js_files = NbMetadataCollector.get_js_files(app.env, pagename) # type: ignore for path, kwargs in js_files.values(): app.add_js_file(path, **kwargs) # type: ignore - - -def update_togglebutton_classes(app: Sphinx, config): - """Update togglebutton classes to recognise hidden cell inputs/outputs.""" - to_add = [ - ".tag_hide_input div.cell_input", - ".tag_hide-input div.cell_input", - ".tag_hide_output div.cell_output", - ".tag_hide-output div.cell_output", - ".tag_hide_cell.cell", - ".tag_hide-cell.cell", - ] - for selector in to_add: - config.togglebutton_selector += f", {selector}" diff --git a/myst_nb/static/mystnb.css b/myst_nb/static/mystnb.css index 46655d85..7b8b4d91 100644 --- a/myst_nb/static/mystnb.css +++ b/myst_nb/static/mystnb.css @@ -15,13 +15,70 @@ div.container.cell { } /* Input cells */ -div.cell div.cell_input { +div.cell div.cell_input, +div.cell details.above-input > summary { padding-left: 0em; padding-right: 0em; border: 1px #ccc solid; background-color: #f7f7f7; border-left-color: green; border-left-width: medium; + border-radius: .4em; +} + +div.cell details.above-input div.cell_input { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: 1px #ccc dashed; +} + +div.cell details.above-input > summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 1px #ccc dashed; + padding-left: 1em; + margin-bottom: 0; +} + +div.cell details.above-output > summary { + background-color: #f7f7f7; + padding-left: 1em; + padding-right: 0em; + border: 1px #ccc solid; + border-bottom: 1px #ccc dashed; + border-left-color: blue; + border-left-width: medium; + border-top-left-radius: .4em; + border-top-right-radius: .4em; +} + +div.cell details.hide > summary::marker { + opacity: 50%; +} + +div.cell details.hide > summary > span { + opacity: 50%; +} + +div.cell details.hide[open] > summary > span.collapsed { + display: none; +} +div.cell details.hide:not([open]) > summary > span.expanded { + display: none; +} + +@keyframes collapsed-fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +div.cell details.hide[open] > summary ~ * { + -moz-animation: collapsed-fade-in 0.3s ease-in-out; + -webkit-animation: collapsed-fade-in 0.3s ease-in-out; + animation: collapsed-fade-in 0.3s ease-in-out; } div.cell_input > div, div.cell_output div.output > div.highlight { diff --git a/pyproject.toml b/pyproject.toml index 89570c9e..18261905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ dependencies = [ "nbformat~=5.0", "pyyaml", "sphinx>=4,<6", - "sphinx-togglebutton~=0.3.0", "typing-extensions", # ipykernel is not a requirement of the library, # but is a common requirement for users (registers the python3 kernel) @@ -68,7 +67,7 @@ ipythontb = "myst_nb.core.lexers:IPythonTracebackLexer" myst_nb_md = "myst_nb.core.read:myst_nb_reader_plugin" [project.optional-dependencies] -code_style = ["pre-commit~=2.12"] +code_style = ["pre-commit"] rtd = [ "alabaster", "altair", @@ -91,10 +90,13 @@ testing = [ "coverage~=6.4", "beautifulsoup4", "ipykernel~=5.5", - "ipython!=8.1.0", # see https://github.com/ipython/ipython/issues/13554 + # for issue with 8.1.0 see https://github.com/ipython/ipython/issues/13554 + # TODO ipython 8.5 subtly changes output of test regressions + # see https://ipython.readthedocs.io/en/stable/whatsnew/version8.html#restore-line-numbers-for-input + "ipython!=8.1.0,<8.5", "ipywidgets>=8", "jupytext~=1.11.2", - "matplotlib>=3.5.3", + "matplotlib>=3.5.3,<3.6", # TODO mpl 3.6 subtly changes output of test regressions "nbdime", "numpy", "pandas", diff --git a/tests/notebooks/hide_cell_content.ipynb b/tests/notebooks/hide_cell_content.ipynb new file mode 100644 index 00000000..8cbbca9c --- /dev/null +++ b/tests/notebooks/hide_cell_content.ipynb @@ -0,0 +1,126 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hide Code Cell Content" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [ + "hide-input" + ] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "hide-input\n" + ] + } + ], + "source": [ + "print(\"hide-input\")" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "tags": [ + "hide-output" + ] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "hide-output\n" + ] + } + ], + "source": [ + "print(\"hide-output\")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "tags": [ + "hide-cell" + ] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "hide-cell\n" + ] + } + ], + "source": [ + "print(\"hide-cell\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "tags": [ + "hide-cell" + ], + "mystnb": { + "code_prompt_show": "My show message", + "code_prompt_hide": "My hide message" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "hide-cell custom message\n" + ] + } + ], + "source": [ + "print(\"hide-cell custom message\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.8.13", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.13" + }, + "orig_nbformat": 4, + "vscode": { + "interpreter": { + "hash": "321f99720af1749431335326d75386e6232ab33d0a78426e9f427a66c2c329a4" + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/test_render_outputs.py b/tests/test_render_outputs.py index f4b8862f..5cb72f1a 100644 --- a/tests/test_render_outputs.py +++ b/tests/test_render_outputs.py @@ -144,3 +144,12 @@ def test_unknown_mimetype(sphinx_run, file_regression): assert warning in sphinx_run.warnings() doctree = sphinx_run.get_resolved_doctree("unknown_mimetype") file_regression.check(doctree.pformat(), extension=".xml", encoding="utf8") + + +@pytest.mark.sphinx_params("hide_cell_content.ipynb", conf={"nb_execution_mode": "off"}) +def test_hide_cell_content(sphinx_run, file_regression): + """Test that hiding cell contents produces the correct AST.""" + sphinx_run.build() + assert sphinx_run.warnings() == "" + doctree = sphinx_run.get_resolved_doctree("hide_cell_content") + file_regression.check(doctree.pformat(), extension=".xml", encoding="utf8") diff --git a/tests/test_render_outputs/test_hide_cell_content.xml b/tests/test_render_outputs/test_hide_cell_content.xml new file mode 100644 index 00000000..63f0e8e2 --- /dev/null +++ b/tests/test_render_outputs/test_hide_cell_content.xml @@ -0,0 +1,36 @@ + +
+ + Hide Code Cell Content + <container cell_index="1" cell_metadata="{'tags': ['hide-input']}" classes="cell tag_hide-input" exec_count="1" hide_mode="input" nb_element="cell_code" prompt_hide="Hide code cell {type}" prompt_show="Show code cell {type}"> + <HideCodeCellNode classes="above-input" prompt_hide="Hide code cell source" prompt_show="Show code cell source"> + <container classes="cell_input" nb_element="cell_code_source"> + <literal_block language="ipython3" linenos="False" xml:space="preserve"> + print("hide-input") + <container classes="cell_output" nb_element="cell_code_output"> + <literal_block classes="output stream" language="myst-ansi" linenos="False" xml:space="preserve"> + hide-input + <container cell_index="2" cell_metadata="{'tags': ['hide-output']}" classes="cell tag_hide-output" exec_count="2" hide_mode="output" nb_element="cell_code" prompt_hide="Hide code cell {type}" prompt_show="Show code cell {type}"> + <container classes="cell_input" nb_element="cell_code_source"> + <literal_block language="ipython3" linenos="False" xml:space="preserve"> + print("hide-output") + <HideCodeCellNode classes="above-output" prompt_hide="Hide code cell outputs" prompt_show="Show code cell outputs"> + <container classes="cell_output" nb_element="cell_code_output"> + <literal_block classes="output stream" language="myst-ansi" linenos="False" xml:space="preserve"> + hide-output + <container cell_index="3" cell_metadata="{'tags': ['hide-cell']}" classes="cell tag_hide-cell" exec_count="4" hide_mode="all" nb_element="cell_code" prompt_hide="Hide code cell {type}" prompt_show="Show code cell {type}"> + <HideCodeCellNode classes="above-input" prompt_hide="Hide code cell content" prompt_show="Show code cell content"> + <container classes="cell_input" nb_element="cell_code_source"> + <literal_block language="ipython3" linenos="False" xml:space="preserve"> + print("hide-cell") + <container classes="cell_output" nb_element="cell_code_output"> + <literal_block classes="output stream" language="myst-ansi" linenos="False" xml:space="preserve"> + hide-cell + <container cell_index="4" cell_metadata="{'tags': ['hide-cell'], 'mystnb': {'code_prompt_show': 'My show message', 'code_prompt_hide': 'My hide message'}}" classes="cell tag_hide-cell" exec_count="5" hide_mode="all" nb_element="cell_code" prompt_hide="My hide message" prompt_show="My show message"> + <HideCodeCellNode classes="above-input" prompt_hide="My hide message" prompt_show="My show message"> + <container classes="cell_input" nb_element="cell_code_source"> + <literal_block language="ipython3" linenos="False" xml:space="preserve"> + print("hide-cell custom message") + <container classes="cell_output" nb_element="cell_code_output"> + <literal_block classes="output stream" language="myst-ansi" linenos="False" xml:space="preserve"> + hide-cell custom message diff --git a/tox.ini b/tox.ini index dabade26..817a6f79 100644 --- a/tox.ini +++ b/tox.ini @@ -27,13 +27,15 @@ commands = pytest {posargs} extras = rtd deps = ipython<=7.11.0 # required by coconut +setenv = + BUILDER = {env:BUILDER:html} whitelist_externals = echo rm commands = clean: rm -rf docs/_build - sphinx-build -nW --keep-going -b {posargs:html} docs/ docs/_build/{posargs:html} -commands_post = echo "open file://{toxinidir}/docs/_build/{posargs:html}/index.html" + sphinx-build {posargs} -nW --keep-going -b {env:BUILDER} docs/ docs/_build/{env:BUILDER} +commands_post = echo "open file://{toxinidir}/docs/_build/{env:BUILDER}/index.html" [pytest]