Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## Unreleased

### Changed ⚠️

- `exercise_style = "solution_follow_exercise"` now renders solutions **collapsed by default** ([#84](https://github.com/executablebooks/sphinx-exercise/issues/84))
- That style places each solution directly beneath its exercise, where an expanded solution is too tempting to read; it now folds into a drop-down instead
- **To keep the previous behaviour, set `solution_collapsed = False` explicitly**
- No change for projects that do not set `exercise_style`, and no source files need editing either way
- Requires `sphinx_togglebutton`, which Jupyter Book loads by default; plain Sphinx projects should add it to `extensions`

### New ✨

- Added `solution_collapsed` configuration option to control whether solutions render folded ([#85](https://github.com/executablebooks/sphinx-exercise/issues/85))
- Tri-state: unset follows `exercise_style`, `True` always collapses, `False` never collapses
- Works with both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs
- Directive-level `:class:` values are preserved, and `:class: toggle-shown` keeps an individual solution expanded
- A warning is issued during HTML builds when collapsing is in effect but `sphinx_togglebutton` is not loaded, suppressible with `suppress_warnings = ["exercise.solution_collapsed"]`
- Non-HTML builders, such as LaTeX/PDF, render solutions inline as before

## [v1.2.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.2.1) (2025-11-17)

### Fixes 🐛
Expand Down
1 change: 1 addition & 0 deletions docs/source/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This section contains detailed release notes for sphinx-exercise versions.
```{toctree}
:maxdepth: 1

v1.3.0
v1.2.1
v1.2.0
v1.1.1
Expand Down
88 changes: 88 additions & 0 deletions docs/source/releases/v1.3.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Release v1.3.0

**Release Date**: unreleased

This release adds a `solution_collapsed` configuration option for rendering solutions folded by default, and makes collapsing part of what `exercise_style = "solution_follow_exercise"` means.

## ⚠️ Behaviour change

**If you set `exercise_style = "solution_follow_exercise"`, your solutions will now render collapsed by default.**

This style places each solution directly beneath its exercise. Reader feedback on books using it was that an adjacent, fully visible solution is too tempting to look at, so the style now folds solutions into a drop-down and readers opt in to seeing the answer.

### Keeping the previous behaviour

Set `solution_collapsed` to `False` explicitly:

```python
# In conf.py
exercise_style = "solution_follow_exercise"
solution_collapsed = False
```

Or for Jupyter Book:

```yaml
# In _config.yml
sphinx:
config:
exercise_style: "solution_follow_exercise"
solution_collapsed: False
```

Nothing changes for projects that do not set `exercise_style`, and no source files need editing either way.

### Requirements

Collapsing is provided by [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/), which supplies the drop-down behaviour for the `dropdown` class. Jupyter Book loads it as part of its default extension list, so Jupyter Book projects need no change.

Plain Sphinx projects should add it:

```python
# In conf.py
extensions = [
...
"sphinx_togglebutton"
...
]
```

If solutions would be collapsed but the extension is not loaded, the build emits a warning naming both remedies — add the extension, or set `solution_collapsed = False` — and solutions render expanded rather than silently losing content.

## ✨ New Features

### Collapsing solutions

`solution_collapsed` controls whether solutions render folded, independently of the exercise style. It takes three values:

| Value | Behaviour |
|---|---|
| unset (default) | Follow the exercise style: collapsed when `exercise_style = "solution_follow_exercise"`, expanded otherwise. |
| `True` | Always collapse solutions, whatever the exercise style. |
| `False` | Never collapse solutions, whatever the exercise style. |

Collapsing is equivalent to adding `:class: dropdown` to every solution directive, and applies to both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs. Classes set on an individual directive are preserved, and an explicit `:class: dropdown` is not duplicated.

### Keeping one solution expanded

Add `:class: toggle-shown` to an individual directive to keep it open while the rest of the project is collapsed:

````md
```{solution} my-exercise
:class: toggle-shown

This solution stays open even when the rest of the project is collapsed.
```
````

## 📝 Notes

**Non-HTML builders are unaffected.** LaTeX/PDF output renders solutions inline as before; the `dropdown` class is only meaningful to HTML.

**Interactive outputs need care.** A collapsed solution is hidden by setting its height to zero rather than by removing it from the page, so outputs that measure their own size when the page loads — plotly, bokeh, ipywidgets and altair figures produced by `{code-cell}` blocks — will render at zero size inside a collapsed solution and may stay blank until the reader opens it. Static images, including matplotlib figures, are unaffected. Use `:class: toggle-shown` on solutions containing interactive outputs.

**Suppressing the warning.** Projects supplying their own `.admonition.dropdown` styling can silence the missing-extension warning with `suppress_warnings = ["exercise.solution_collapsed"]`.

## 📚 Documentation

See [Collapse All Solutions](../syntax.md) in the syntax guide for full details.
66 changes: 66 additions & 0 deletions docs/source/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,71 @@ sphinx:
...
```

### Collapse All Solutions

All solution directives can be rendered folded by default, so readers have to opt in to seeing the answer. This is controlled by `solution_collapsed`, which takes three values:

| Value | Behaviour |
|---|---|
| unset (default) | Follow the exercise style: solutions are collapsed when `exercise_style = "solution_follow_exercise"`, and expanded otherwise. |
| `True` | Always collapse solutions, whatever the exercise style. |
| `False` | Never collapse solutions, whatever the exercise style. |

The `solution_follow_exercise` style places each solution directly beneath its exercise, which is precisely the layout where an expanded solution is hard to look away from — so that style collapses solutions by default. See the **Solution Title Styling** section below.

```{important}
If you use `exercise_style = "solution_follow_exercise"` and want your solutions to stay expanded, set `solution_collapsed = False` explicitly.
```

This option requires [sphinx-togglebutton](https://sphinx-togglebutton.readthedocs.io/en/latest/) to be enabled, as it provides the drop-down behaviour for the `dropdown` class. For Sphinx projects, add the configuration key in the `conf.py` file:

```python
# conf.py
extensions = [
...
"sphinx_togglebutton"
...
]

solution_collapsed = True
```

For Jupyter Book projects, set the configuration key in `_config.yml`:

```yaml
...
sphinx:
extra_extensions:
- sphinx_togglebutton
config:
solution_collapsed: True
...
```

Collapsing is equivalent to adding `:class: dropdown` to every solution directive in your project, and applies to both the `{solution}` directive and gated `{solution-start}` / `{solution-end}` pairs. Any classes you have set on an individual directive are preserved.

```{note}
The `dropdown` class only affects HTML output. Other builders, such as LaTeX/PDF, render the solution inline as usual.

If solutions would be collapsed but `sphinx_togglebutton` is not loaded, a warning is issued during an HTML build and solutions render expanded. If your theme supplies its own `.admonition.dropdown` styling and you do not need the extension, silence the warning with `suppress_warnings = ["exercise.solution_collapsed"]`.
```

```{warning}
A collapsed solution is hidden by setting its height to zero rather than by removing it from the page. Outputs that measure their own size when the page loads — such as plotly, bokeh, ipywidgets and altair figures produced by `{code-cell}` blocks — will therefore render at zero size inside a collapsed solution, and may stay blank until the reader toggles it open.

Static images, including matplotlib figures, are unaffected. If a solution contains an interactive output, keep that one expanded with `:class: toggle-shown`.
```

To keep an individual solution expanded while the rest of the project is collapsed, add `:class: toggle-shown` to that directive:

````md
```{solution} my-exercise
:class: toggle-shown

This solution stays open even when the rest of the project is collapsed.
```
````

### Solution Title Styling

By default, solution titles include a hyperlink to the corresponding exercise. This behavior can be modified using the `exercise_style` configuration option.
Expand All @@ -436,6 +501,7 @@ sphinx:

When `exercise_style` is set to `"solution_follow_exercise"`:
- The solution title displays just "Solution" (plain text, no hyperlink)
- **Solutions are collapsed by default**, so readers opt in to seeing the answer. Set `solution_collapsed = False` to keep them expanded, and see the **Collapse All Solutions** section above for the details
- The extension validates that solutions follow their referenced exercises and warns if they don't
- Solutions must be in the same document as their exercises (warnings if not)

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ testing = [
"pytest-regressions",
"pytest>=8.0",
"sphinx>=6.1,<9",
"sphinx_togglebutton",
"texsoup",
"defusedxml", # Required by sphinx-testing
]
Expand Down
62 changes: 62 additions & 0 deletions sphinx_exercise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from sphinx.locale import get_translation

from ._compat import findall
from .utils import solutions_are_collapsed, collapsed_is_implied_by_style
from .directive import (
ExerciseDirective,
ExerciseStartDirective,
Expand Down Expand Up @@ -272,11 +273,72 @@ def doctree_read(app: Sphinx, document: Node) -> None:
)


# Extensions that make the "dropdown" class collapsible.
#
# Only sphinx-togglebutton qualifies: its default togglebutton_selector is
# ".toggle, .admonition.dropdown". Note that sphinx-design does NOT belong
# here - its dropdown is a directive emitting ".sd-dropdown", and it ships no
# rule for a bare "dropdown" class. Adding it would suppress the warning below
# for Jupyter Book projects, which load sphinx-design by default.
TOGGLE_EXTENSIONS = ("sphinx_togglebutton",)


def check_collapsed_solutions(app: Sphinx) -> None:
"""
Warn when solution_collapsed is enabled for an HTML build but no extension
that implements the "dropdown" class is loaded.

Without one of TOGGLE_EXTENSIONS the class is inert, so solutions would
render fully expanded and the option would silently do nothing.

Projects that supply their own ".admonition.dropdown" CSS can silence this
with suppress_warnings = ["exercise.solution_collapsed"].
"""
if not solutions_are_collapsed(app.config):
return

# The dropdown class is only meaningful to HTML-family builders; LaTeX and
# other builders render the solution inline, which is the intended fallback
if getattr(app.builder, "format", None) != "html":
return

if any(ext in app.extensions for ext in TOGGLE_EXTENSIONS):
return

if collapsed_is_implied_by_style(app.config):
# The author never asked for collapsing, so tell them how to turn it
# off as well as how to make it work
message = (
"exercise_style='solution_follow_exercise' collapses solutions by "
"default, but 'sphinx_togglebutton' is not loaded, so they will "
"render expanded. Add 'sphinx_togglebutton' to your extensions, or "
"set solution_collapsed = False to keep solutions expanded."
)
else:
message = (
"solution_collapsed=True requires 'sphinx_togglebutton', which is "
"not loaded, so solutions will render expanded. Add "
"'sphinx_togglebutton' to your extensions."
)

logger.warning(
f"[sphinx-exercise] {message} "
"See https://sphinx-togglebutton.readthedocs.io",
type="exercise",
subtype="solution_collapsed",
color="yellow",
)


def setup(app: Sphinx) -> Dict[str, Any]:
app.add_config_value("hide_solutions", False, "env")
app.add_config_value("exercise_style", "", "env")
# Tri-state: None (default) defers to exercise_style, True/False are
# explicit author choices. See utils.solutions_are_collapsed.
app.add_config_value("solution_collapsed", None, "env")

app.connect("config-inited", init_numfig) # event order - 1
app.connect("builder-inited", check_collapsed_solutions) # event order - 2
app.connect("env-purge-doc", purge_exercises) # event order - 5 per file
app.connect("doctree-read", doctree_read) # event order - 8
app.connect("env-merge-info", merge_exercises) # event order - 9
Expand Down
10 changes: 10 additions & 0 deletions sphinx_exercise/directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from sphinx.util import logging
from sphinx.util.docutils import SphinxDirective

from .utils import solutions_are_collapsed
from .nodes import (
exercise_end_node,
exercise_enumerable_node,
Expand Down Expand Up @@ -267,6 +268,15 @@ def run(self) -> List[Node]:
if self.options.get("class"):
classes += self.options.get("class")

# Fold the solution by default when collapsing is in effect - either
# opted into with solution_collapsed, or implied by the
# solution_follow_exercise style. The "dropdown" class is consumed by
# sphinx-togglebutton, whose default selector is
# ".toggle, .admonition.dropdown". Authors can still opt an individual
# solution back open with :class: toggle-shown.
if solutions_are_collapsed(self.env.app.config) and "dropdown" not in classes:
classes.append("dropdown")

# Construct Node
node = self.solution_node()
node += title
Expand Down
37 changes: 37 additions & 0 deletions sphinx_exercise/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,43 @@

from sphinx.writers.latex import LaTeXTranslator

#: The exercise style that places solutions directly after their exercises,
#: and therefore implies collapsed solutions unless the author opts out.
SOLUTION_FOLLOW_EXERCISE = "solution_follow_exercise"


def solutions_are_collapsed(config) -> bool:
"""Whether solution directives should render folded by default.

``solution_collapsed`` is deliberately tri-state:

``True`` / ``False``
An explicit choice by the author, which always wins.
``None`` (the default)
Defer to ``exercise_style``. The ``solution_follow_exercise`` style puts
the solution directly beneath its exercise, which is precisely the
layout where an expanded solution is hard to look away from, so that
style implies collapsed solutions.

A plain ``False`` default could not express this, because Sphinx cannot
distinguish "unset" from "explicitly set to False" through the public
config API - so ``solution_collapsed = False`` would be unable to switch
the style's implied collapsing back off.
"""
if config.solution_collapsed is not None:
return bool(config.solution_collapsed)
return config.exercise_style == SOLUTION_FOLLOW_EXERCISE


def collapsed_is_implied_by_style(config) -> bool:
"""Whether collapsing came from ``exercise_style`` rather than an explicit opt-in.

Used to tailor the "sphinx-togglebutton is missing" warning, since an author
who never asked for collapsing needs to be told how to switch it off as well
as how to make it work.
"""
return config.solution_collapsed is None and solutions_are_collapsed(config)


def find_parent(env, node, parent_tag):
"""Find the nearest parent node with the given tagname."""
Expand Down
7 changes: 7 additions & 0 deletions tests/books/test-mybook/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,10 @@ A Test Program!

solution/_linked_ref_wronglabel
solution/_linked_duplicate_label

.. NOTE: append new entries here. Documents containing enumerated
exercises must never be inserted above existing entries, or the
global exercise numbers baked into the regression fixtures shift.

solution/_linked_enum_dropdown
solution/_linked_gated
8 changes: 8 additions & 0 deletions tests/books/test-mybook/solution/_linked_enum_dropdown.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
_linked_enum_dropdown
=====================

.. solution:: ex-number
:label: solution-dropdown-label
:class: dropdown

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
15 changes: 15 additions & 0 deletions tests/books/test-mybook/solution/_linked_gated.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
_linked_gated
=============

.. exercise:: A gated example
:label: gated-ex-label
:nonumber:

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

.. solution-start:: gated-ex-label
:label: gated-solution-label

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

.. solution-end::
Loading
Loading