Repository: aeye-lab/pymovements (python)

Issue:
HTML reprs for notebooks
## Description of the problem

The most important classes (gaze and event dataframes, experiment metadata etc.) either don't have a `__repr__` at all, or it is incomplete and doesn't look very good. For a lot of people who use pymovements in interactive IPython sessions or notebooks (like myself), presenting data in a nicer way with HTML could be very helpful when learning the library or when exploring datasets. These representations could also be rendered in tutorial notebooks on the website.

## Description of a solution

IPython allows implementing `_repr_html_()`, which should be quite easy to implement for most classes. I imagine something like `xarray` does, with collapsible nested structures:

![Image](https://github.com/user-attachments/assets/7bed44bf-84cf-41eb-8c26-5d405e6fbbaf)
(from [here](https://docs.xarray.dev/en/stable/examples/weather-data.html))

For example, the gaze dataframe could have sections for the sample dataframe, the event dataframe, the experiment metadata etc.

## Minimum acceptance criteria

Some user-friendly and useful HTML representations for the most important classes (probably mainly `Dataset`, `GazeDataframe` and `EventDataframe`?).

## Sample Code

```py
>>> gaze = from_asc("data.asc")

► frame: (1234 samples)
▼ events: (56 events)
┌──────────────────┬─────────┬─────────┬──────────┐
│ name             ┆ onset   ┆ offset  ┆ duration │
│ ---              ┆ ---     ┆ ---     ┆ ---      │
│ str              ┆ i64     ┆ i64     ┆ i64      │
╞══════════════════╪═════════╪═════════╪══════════╡
│ fixation_eyelink ┆ 392776  ┆ 393586  ┆ 810      │
│ saccade_eyelink  ┆ 393587  ┆ 393604  ┆ 17       │
│ fixation_eyelink ┆ 395419  ┆ 395574  ┆ 155      │
│ saccade_eyelink  ┆ 395575  ┆ 395609  ┆ 34       │
│ fixation_eyelink ┆ 395610  ┆ 395915  ┆ 305      │
│ …                ┆ …       ┆ …       ┆ …        │
│ fixation_eyelink ┆ 3141163 ┆ 3141348 ┆ 185      │
│ saccade_eyelink  ┆ 3141349 ┆ 3141380 ┆ 31       │
│ fixation_eyelink ┆ 3141381 ┆ 3141721 ┆ 340      │
│ saccade_eyelink  ┆ 3141722 ┆ 3141754 ┆ 32       │
│ fixation_eyelink ┆ 3141755 ┆ 3141984 ┆ 229      │
└──────────────────┴─────────┴─────────┴──────────┘
► experiment: ...
```

Pull request description:
feat: HTML representations for Jupyter notebooks
## Description

HTML representation for the most important classes (`GazeDataFrame`, `EventDataFrame`, `Experiment`, `Dataset`, etc.). Currently, it is simply a list with collapsible sections for selected attributes of the objects.

Suggestions for design improvements are welcome.

Preview: https://pymovements--1112.org.readthedocs.build/en/1112/tutorials/detecting-events.html

## Implemented changes

- [x] Utility function for generating generic HTML representations
- [x] Class decorator that adds `_repr_html_()` automatically
- [x] Representations for 
  - [x] `GazeDataFrame`
  - [x] `EventDataFrame`
  - [x] `Experiment`
  - [x] `EyeTracker`
  - [x] `Screen`
  - [x] `TextStimulus`
  - [x] `ImageStimulus`
  - [x] `Dataset`
  - [x] `DatasetDefinition`

## How Has This Been Tested?

- [x] Unit tests for code involved in the `@html_repr` decorator

## Type of change

- New functionality

## Context

Resolves #1014

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8acc0ee..27eab09 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -119,7 +119,7 @@ repos:
         files: ^tests/
         args:
           [
-            '--ignore=D103,D107,D213',
+            '--ignore=D103,D107,D203,D213',
           ]
 -   repo: https://github.com/PyCQA/pylint
     rev: v3.3.7
diff --git a/src/pymovements/_utils/_html.py b/src/pymovements/_utils/_html.py
new file mode 100644
index 0000000..42ae27a
--- /dev/null
+++ b/src/pymovements/_utils/_html.py
@@ -0,0 +1,201 @@
+# Copyright (c) 2025 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Provides functions for generating HTML representations of objects for Jupyter notebooks."""
+from __future__ import annotations
+
+from collections.abc import Callable
+from html import escape
+from itertools import islice
+from typing import TypeVar
+from uuid import uuid4
+
+import polars as pl
+
+
+STYLE = """
+<style>
+    .pymovements-section-list {
+        margin: 0;
+        padding: 0;
+        font-family: sans-serif;
+    }
+    .pymovements-section {
+        list-style: none;
+        padding-bottom: 0.5em;
+    }
+    .pymovements-section-title {
+        font-size: 120%;
+        font-weight: bold;
+    }
+    .pymovements-section-toggle {
+        display: none;
+    }
+    .pymovements-section-label {
+        cursor: pointer;
+        font-weight: bold;
+    }
+    .pymovements-section-label:before {
+        display: inline-block;
+        content: "►";
+    }
+    .pymovements-section-toggle:checked + .pymovements-section-label:before {
+        content: "▼";
+    }
+    .pymovements-section-inline-details {
+        display: inline-block;
+    }
+    .pymovements-section-details {
+        display: none;
+        padding-left: 1em;
+    }
+    .pymovements-section-toggle:checked ~ .pymovements-section-details {
+        display: block;
+    }
+    .pymovements-section-details > ul {
+        padding-left: 1em;
+    }
+</style>
+"""
+
+
+T = TypeVar('T')
+
+
+def repr_html(attrs: list[str] | None = None) -> Callable[[T], T]:
+    """Add an HTML representation to the class for Jupyter notebooks.
+
+    Parameters
+    ----------
+    attrs : list[str] | None
+        List of attributes to include in the HTML representation.
+        If None, all public attributes are included.
+
+    Returns
+    -------
+    Callable[[T], T]
+        Decorator function that adds the HTML representation to the decorated class.
+    """
+
+    def decorator(cls: T) -> T:
+        setattr(cls, '_repr_html_', lambda self: _obj_html(self, attrs))
+        return cls
+
+    return decorator
+
+
+def _obj_html(obj: object, attrs: list[str] | None = None) -> str:
+    """Generate HTML representation for an object."""
+    if attrs is None:
+        attrs = []
+        for attr in dir(obj):
+            # Skip private attributes
+            if attr.startswith('_'):
+                continue
+            # Skip properties that raise errors
+            try:
+                value = getattr(obj, attr)
+            except BaseException:
+                continue
+            # Skip methods
+            if not callable(value):
+                attrs.append(attr)
+
+    title = escape(type(obj).__name__)
+
+    sections = []
+    for attr in attrs:
+        attr_obj = getattr(obj, attr)
+        sections.append(_attr_html(attr, attr_obj))
+
+    return f"""
+    {STYLE}
+    <span class="pymovements-section-title">{title}</span>
+    <ul class="pymovements-section-list">
+        {"".join(sections)}
+    </ul>
+    """
+
+
+def _attr_html(name: str, obj: object, depth: int = 0) -> str:
+    """Generate HTML representation for an attribute."""
+    section_id = uuid4()
+    name = escape(name)
+
+    inline_details = _attr_inline_details_html(obj)
+    details = _attr_details_html(obj, depth=depth)
+
+    return f"""
+    <li class="pymovements-section">
+        <input id="pymovements-{section_id}" class="pymovements-section-toggle" type="checkbox">
+        <label for="pymovements-{section_id}" class="pymovements-section-label">{name}:</label>
+        <div class="pymovements-section-inline-details">{inline_details}</div>
+        <div class="pymovements-section-details">{details}</div>
+    </li>
+    """
+
+
+def _attr_inline_details_html(obj: object) -> str:
+    """Generate inline (collapsed) details for HTML representation."""
+    if isinstance(obj, pl.DataFrame):
+        inline_details = f"DataFrame ({len(obj.columns)} columns, {len(obj)} rows)"
+
+    elif isinstance(obj, list):
+        inline_details = f"list ({len(obj)} items)"
+
+    elif isinstance(obj, dict):
+        inline_details = f"dict ({len(obj)} items)"
+
+    elif len(repr(obj)) < 50:
+        inline_details = repr(obj).replace('\n', ' ')
+
+    else:
+        inline_details = type(obj).__name__
+
+    return escape(inline_details)
+
+
+def _attr_details_html(obj: object, depth: int = 0, max_depth: int = 3) -> str:
+    """Generate expanded details for HTML representation."""
+    max_items = 2
+
+    if isinstance(obj, list) and depth < max_depth:
+        details = '<ul>'
+        for item in obj[:max_items]:
+            details += f'<li>{_attr_details_html(item, depth + 1)}</li>'
+        if len(obj) > max_items:
+            details += f'<li>({len(obj) - 2} more)</li>'
+        details += '</ul>'
+
+    elif isinstance(obj, dict) and depth < max_depth:
+        details = '<ul class="pymovements-section-list">'
+        for key in islice(obj.keys(), max_items):
+            value = obj[key]
+            details += _attr_html(key, value, depth + 1)
+        if len(obj) > max_items:
+            details += f'<li>({len(obj) - 2} more)</li>'
+        details += '</ul>'
+
+    elif hasattr(obj, '_repr_html_'):
+        details = obj._repr_html_()  # pylint: disable=protected-access
+
+    else:
+        details = escape(repr(obj))
+
+    return details
diff --git a/src/pymovements/dataset/dataset.py b/src/pymovements/dataset/dataset.py
index 66e8b02..25301be 100644
--- a/src/pymovements/dataset/dataset.py
+++ b/src/pymovements/dataset/dataset.py
@@ -30,6 +30,7 @@ from typing import Any
 import polars as pl
 from tqdm.auto import tqdm
 
+from pymovements._utils._html import repr_html
 from pymovements.dataset import dataset_download
 from pymovements.dataset import dataset_files
 from pymovements.dataset.dataset_definition import DatasetDefinition
@@ -45,6 +46,7 @@ logging.basicConfig(level=logging.INFO)
 logger = logging.getLogger(__name__)
 
 
+@repr_html()
 class Dataset:
     """Dataset base class.
 
diff --git a/src/pymovements/dataset/dataset_definition.py b/src/pymovements/dataset/dataset_definition.py
index 3725ea7..df50c24 100644
--- a/src/pymovements/dataset/dataset_definition.py
+++ b/src/pymovements/dataset/dataset_definition.py
@@ -28,6 +28,7 @@ from typing import Any
 
 import yaml
 
+from pymovements._utils._html import repr_html
 from pymovements.dataset._utils._yaml import reverse_substitute_types
 from pymovements.dataset._utils._yaml import substitute_types
 from pymovements.dataset._utils._yaml import type_constructor
@@ -37,6 +38,7 @@ from pymovements.gaze.experiment import Experiment
 yaml.add_multi_constructor('!', type_constructor, Loader=yaml.SafeLoader)
 
 
+@repr_html()
 @dataclass
 class DatasetDefinition:
     """Definition to initialize a :py:class:`~pymovements.dataset.Dataset`.
diff --git a/src/pymovements/dataset/dataset_paths.py b/src/pymovements/dataset/dataset_paths.py
index bd47ccc..4025b94 100644
--- a/src/pymovements/dataset/dataset_paths.py
+++ b/src/pymovements/dataset/dataset_paths.py
@@ -22,7 +22,10 @@ from __future__ import annotations
 
 from pathlib import Path
 
+from pymovements._utils._html import repr_html
 
+
+@repr_html()
 class DatasetPaths:
     """Defines the paths of a dataset.
 
diff --git a/src/pymovements/events/frame.py b/src/pymovements/events/frame.py
index 3bd1b52..fa56e36 100644
--- a/src/pymovements/events/frame.py
+++ b/src/pymovements/events/frame.py
@@ -28,10 +28,12 @@ import polars as pl
 from tqdm import tqdm
 
 from pymovements._utils import _checks
+from pymovements._utils._html import repr_html
 from pymovements.events.properties import duration
 from pymovements.stimulus.text import TextStimulus
 
 
+@repr_html(['frame', 'trial_columns'])
 class EventDataFrame:
     """A DataFrame for event data.
 
diff --git a/src/pymovements/events/precomputed.py b/src/pymovements/events/precomputed.py
index 5c01639..303b5b4 100644
--- a/src/pymovements/events/precomputed.py
+++ b/src/pymovements/events/precomputed.py
@@ -22,7 +22,10 @@ from __future__ import annotations
 
 import polars as pl
 
+from pymovements._utils._html import repr_html
 
+
+@repr_html()
 class PrecomputedEventDataFrame:
     """A DataFrame for precomputed eye movement event data.
 
diff --git a/src/pymovements/gaze/experiment.py b/src/pymovements/gaze/experiment.py
index af85023..b2bd449 100644
--- a/src/pymovements/gaze/experiment.py
+++ b/src/pymovements/gaze/experiment.py
@@ -27,11 +27,13 @@ from typing import Any
 import numpy as np
 
 from pymovements._utils import _checks
+from pymovements._utils._html import repr_html
 from pymovements.gaze import transforms_numpy
 from pymovements.gaze.eyetracker import EyeTracker
 from pymovements.gaze.screen import Screen
 
 
+@repr_html()
 class Experiment:
     """Experiment class for holding experiment properties.
 
diff --git a/src/pymovements/gaze/eyetracker.py b/src/pymovements/gaze/eyetracker.py
index 352176d..bb9d7b6 100644
--- a/src/pymovements/gaze/eyetracker.py
+++ b/src/pymovements/gaze/eyetracker.py
@@ -23,8 +23,10 @@ from __future__ import annotations
 from dataclasses import dataclass
 
 from pymovements._utils import _checks
+from pymovements._utils._html import repr_html
 
 
+@repr_html()
 @dataclass
 class EyeTracker:
     """EyeTracker class for holding eyetracker properties.
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index cd4d910..bcecd8b 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -33,11 +33,13 @@ from tqdm import tqdm
 
 import pymovements as pm  # pylint: disable=cyclic-import
 from pymovements._utils._checks import check_is_mutual_exclusive
+from pymovements._utils._html import repr_html
 from pymovements.events.processing import EventGazeProcessor
 from pymovements.gaze import transforms
 from pymovements.gaze.experiment import Experiment
 
 
+@repr_html(['frame', 'events', 'trial_columns', 'experiment'])
 class GazeDataFrame:
     """A DataFrame for gaze time series data.
 
diff --git a/src/pymovements/gaze/screen.py b/src/pymovements/gaze/screen.py
index ad87d30..aa7f00e 100644
--- a/src/pymovements/gaze/screen.py
+++ b/src/pymovements/gaze/screen.py
@@ -25,9 +25,11 @@ from dataclasses import dataclass
 import numpy as np
 
 from pymovements._utils import _checks
+from pymovements._utils._html import repr_html
 from pymovements.gaze import transforms_numpy
 
 
+@repr_html()
 @dataclass
 class Screen:
     """Screen class for holding screen properties.
diff --git a/src/pymovements/stimulus/image.py b/src/pymovements/stimulus/image.py
index 712ccfd..0537ee8 100644
--- a/src/pymovements/stimulus/image.py
+++ b/src/pymovements/stimulus/image.py
@@ -25,10 +25,12 @@ from pathlib import Path
 import matplotlib.pyplot
 import PIL.Image
 
+from pymovements._utils._html import repr_html
 from pymovements._utils._paths import get_filepaths
 from pymovements._utils._strings import curly_to_regex
 
 
+@repr_html()
 class ImageStimulus:
     """A DataFrame for image stimulus.
 
diff --git a/src/pymovements/stimulus/text.py b/src/pymovements/stimulus/text.py
index 2b365c0..e09e867 100644
--- a/src/pymovements/stimulus/text.py
+++ b/src/pymovements/stimulus/text.py
@@ -27,8 +27,10 @@ from typing import Any
 import polars as pl
 
 from pymovements._utils import _checks
+from pymovements._utils._html import repr_html
 
 
+@repr_html(['aois'])
 class TextStimulus:
     """A DataFrame for the text stimulus that the gaze data was recorded on.
 


Test patch:
diff --git a/tests/unit/_utils/_html_test.py b/tests/unit/_utils/_html_test.py
new file mode 100644
index 0000000..ff38e34
--- /dev/null
+++ b/tests/unit/_utils/_html_test.py
@@ -0,0 +1,238 @@
+# Copyright (c) 2025 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Test pymovements HTML representations."""
+import re
+
+import polars as pl
+import pytest
+
+from pymovements._utils import _html
+
+DATAFRAME = pl.DataFrame({'a': [1, 2], 'b': [3, 4]})
+
+
+class Foo:
+    """Test class for HTML representation."""
+
+    def __init__(self, a: int, b: str) -> None:
+        self.a = a
+        self.b = b
+        self._private = 'private'  # Should be excluded from the HTML representation
+
+    @property
+    def working_property(self) -> str:
+        """Properties should be included in the HTML representation."""
+        return f'{self.a} {self.b}'
+
+    @property
+    def failing_property(self) -> None:
+        """Properties that raise an error should be excluded from the HTML representation."""
+        raise RuntimeError()
+
+    def method(self) -> None:
+        """All methods should be excluded from the HTML representation."""
+
+
+@pytest.mark.parametrize(
+    ('cls', 'attrs', 'init_args', 'init_kwargs', 'expected_html'),
+    [
+        pytest.param(
+            Foo,
+            None,
+            (123, 'test'),
+            {},
+            r'<span class="pymovements-section-title">Foo</span>\s*'
+            r'<ul class="pymovements-section-list">\s*'
+            r'<li class="pymovements-section">\s*'
+            r'<input id="pymovements-([0-9a-f-]+)" class="pymovements-section-toggle" '
+            r'type="checkbox">\s*'
+            r'<label for="pymovements-\1" class="pymovements-section-label">a:</label>\s*'
+            r'<div class="pymovements-section-inline-details">123</div>\s*'
+            r'<div class="pymovements-section-details">123</div>\s*'
+            r'</li>\s*'
+            r'<li class="pymovements-section">\s*'
+            r'<input id="pymovements-([0-9a-f-]+)" class="pymovements-section-toggle" '
+            r'type="checkbox">\s*'
+            r'<label for="pymovements-\2" class="pymovements-section-label">b:</label>\s*'
+            r'<div class="pymovements-section-inline-details">&#x27;test&#x27;</div>\s*'
+            r'<div class="pymovements-section-details">&#x27;test&#x27;</div>\s*'
+            r'</li>\s*'
+            r'<li class="pymovements-section">\s*'
+            r'<input id="pymovements-([0-9a-f-]+)" class="pymovements-section-toggle" '
+            r'type="checkbox">\s*'
+            r'<label for="pymovements-\3" class="pymovements-section-label">working_property:'
+            r'</label>\s*'
+            r'<div class="pymovements-section-inline-details">&#x27;123 test&#x27;</div>\s*'
+            r'<div class="pymovements-section-details">&#x27;123 test&#x27;</div>\s*'
+            r'</li>\s*'
+            r'</ul>\s*',
+            id='all_attrs',
+        ),
+        pytest.param(
+            Foo,
+            ['a'],
+            (123, 'test'),
+            {},
+            r'<span class="pymovements-section-title">Foo</span>\s*'
+            r'<ul class="pymovements-section-list">\s*'
+            r'<li class="pymovements-section">\s*'
+            r'<input id="pymovements-([0-9a-f-]+)" class="pymovements-section-toggle" '
+            r'type="checkbox">\s*'
+            r'<label for="pymovements-\1" class="pymovements-section-label">a:</label>\s*'
+            r'<div class="pymovements-section-inline-details">123</div>\s*'
+            r'<div class="pymovements-section-details">123</div>\s*'
+            r'</li>\s*'
+            r'</ul>\s*',
+            id='one_attr',
+        ),
+    ],
+)
+def test_html_repr(cls, attrs, init_args, init_kwargs, expected_html):
+    # Apply decorator
+    cls = _html.repr_html(attrs)(cls)
+    # Create instance of the class
+    obj = cls(*init_args, **init_kwargs)
+    # Get HTML representation
+    html = obj._repr_html_()
+    assert re.search(expected_html, html, re.MULTILINE)
+
+
+@pytest.mark.parametrize(
+    ('obj', 'expected_html'),
+    [
+        pytest.param(
+            'abc\ndef',
+            '&#x27;abc\\ndef&#x27;',
+            id='string_short',
+        ),
+        pytest.param(
+            'x' * 100,
+            'str',
+            id='long_short',
+        ),
+        pytest.param(
+            [1, 2, 3],
+            'list (3 items)',
+            id='list',
+        ),
+        pytest.param(
+            {'a': 1, 'b': 2},
+            'dict (2 items)',
+            id='dict',
+        ),
+        pytest.param(
+            DATAFRAME,
+            'DataFrame (2 columns, 2 rows)',
+            id='dataframe',
+        ),
+    ],
+)
+def test_attr_inline_details_html(obj, expected_html):
+    html = _html._attr_inline_details_html(obj)
+    assert html == expected_html
+
+
+@pytest.mark.parametrize(
+    ('obj', 'expected_html', 'regex'),
+    [
+        pytest.param(
+            'abc\ndef',
+            '&#x27;abc\\ndef&#x27;',
+            False,
+            id='string',
+        ),
+        pytest.param(
+            [1, 2],
+            '<ul><li>1</li><li>2</li></ul>',
+            False,
+            id='list_short',
+        ),
+        pytest.param(
+            [1, 2, 3, 4, 5],
+            '<ul><li>1</li><li>2</li><li>(3 more)</li></ul>',
+            False,
+            id='list_long',
+        ),
+        pytest.param(
+            [1, [2, [3, [4, [5]]]]],
+            '<ul>'
+            '<li>1</li>'
+            '<li><ul><li>2</li>'
+            '<li><ul><li>3</li>'
+            '<li>[4, [5]]</li>'
+            '</ul></li></ul></li></ul>',
+            False,
+            id='list_deep',
+        ),
+        pytest.param(
+            {'a': 1, 'b': 2},
+            r'^<ul class="pymovements-section-list">\s*'
+            r'<li class="pymovements-section">\s*'
+            r'<input id="pymovements-([0-9a-f-]+)" class="pymovements-section-toggle" '
+            r'type="checkbox">\s*'
+            r'<label for="pymovements-\1" class="pymovements-section-label">a:</label>\s*'
+            r'<div class="pymovements-section-inline-details">1</div>\s*'
+            r'<div class="pymovements-section-details">1</div>\s*</li>\s*'
+            r'<li class="pymovements-section">\s*'
+            r'<input id="pymovements-([0-9a-f-]+)" class="pymovements-section-toggle" '
+            r'type="checkbox">\s*'
+            r'<label for="pymovements-\2" class="pymovements-section-label">b:</label>\s*'
+            r'<div class="pymovements-section-inline-details">2</div>\s*'
+            r'<div class="pymovements-section-details">2</div>\s*'
+            r'</li>\s*'
+            r'</ul>$',
+            True,
+            id='dict_short',
+        ),
+        pytest.param(
+            {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5},
+            r'^<ul class="pymovements-section-list">\s*'
+            r'<li class="pymovements-section">\s*'
+            r'<input id="pymovements-([0-9a-f-]+)" class="pymovements-section-toggle" '
+            r'type="checkbox">\s*'
+            r'<label for="pymovements-\1" class="pymovements-section-label">a:</label>\s*'
+            r'<div class="pymovements-section-inline-details">1</div>\s*'
+            r'<div class="pymovements-section-details">1</div>\s*</li>\s*'
+            r'<li class="pymovements-section">\s*'
+            r'<input id="pymovements-([0-9a-f-]+)" class="pymovements-section-toggle" '
+            r'type="checkbox">\s*'
+            r'<label for="pymovements-\2" class="pymovements-section-label">b:</label>\s*'
+            r'<div class="pymovements-section-inline-details">2</div>\s*'
+            r'<div class="pymovements-section-details">2</div>\s*'
+            r'</li>\s*'
+            r'<li>\(3 more\)</li>\s*'
+            r'</ul>$',
+            True,
+            id='dict_long',
+        ),
+        pytest.param(
+            DATAFRAME,
+            DATAFRAME._repr_html_(),
+            False,
+            id='dataframe',
+        ),
+    ],
+)
+def test_attr_details_html(obj, expected_html, regex):
+    html = _html._attr_details_html(obj)
+    if regex:
+        assert re.search(expected_html, html, re.MULTILINE)
+    else:
+        assert html == expected_html