"""Styled-HTML backend for the display description vocabulary.
Unlike the ASCII backend, this renders HTML-native structure: a header with a
metric badge, sections as flex-wrapping panels (so cause/effect sit
side-by-side), and real ``<table>`` elements for both key/value rows and
collections, so converting the card to plain text preserves the pairing of
each label with its value.
"""
from __future__ import annotations
import re
from html import escape
from pyphi.display.description import Description
from pyphi.display.description import Inline
from pyphi.display.description import Nested
from pyphi.display.description import Row
from pyphi.display.description import Section
from pyphi.display.description import Table
from pyphi.display.description import card_label_width
from pyphi.display.numbers import format_column
from pyphi.display.numbers import format_value
# Neutral palette as CSS variables so a single set of rules serves both themes.
# The cause/effect tone colors are colorblind-safe on either background and are
# NOT themed. Light is the default; dark is applied by the theme selectors below.
_LIGHT = (
"--pc-bg:#fff;--pc-fg:#1f2328;--pc-line:#d0d7de;--pc-soft:#eef0f2;"
"--pc-head:#f6f8fa;--pc-muted:#57606a;--pc-faint:#8b949e;"
"--pc-badge-bg:#eef2ff;--pc-badge-fg:#3538cd;--pc-shadow:rgba(0,0,0,.07)"
)
_DARK = (
"--pc-bg:#1c2128;--pc-fg:#e6edf3;--pc-line:#30363d;--pc-soft:#262b31;"
"--pc-head:#22272e;--pc-muted:#9da7b1;--pc-faint:#6e7681;"
"--pc-badge-bg:#1f2544;--pc-badge-fg:#b3bbff;--pc-shadow:rgba(0,0,0,.4)"
)
# Ancestor markers that known front-ends stamp for their own theme. Matching one
# (specificity beats the media query) makes the app's local theme win over the OS
# setting; absent any match, the media query falls back to it. pydata-sphinx-theme
# (the docs) writes the resolved theme into html[data-theme], including auto→OS.
_DARK_HOSTS = (
'body[data-jp-theme-light="false"] .pyphi-card,' # JupyterLab / Notebook 7
".vscode-dark .pyphi-card," # VS Code notebooks
'html[data-theme="dark"] .pyphi-card' # pydata-sphinx-theme (docs)
)
_LIGHT_HOSTS = (
'body[data-jp-theme-light="true"] .pyphi-card,'
".vscode-light .pyphi-card,"
'html[data-theme="light"] .pyphi-card'
)
_STYLE = f"""\
<style>
.pyphi-card{{{_LIGHT};display:inline-block;background:var(--pc-bg);
color:var(--pc-fg);border:1px solid var(--pc-line);border-radius:10px;
overflow:hidden;box-shadow:0 1px 3px var(--pc-shadow);font-size:13px;
font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}}
@media (prefers-color-scheme:dark){{.pyphi-card{{{_DARK}}}}}
{_DARK_HOSTS}{{{_DARK}}}
{_LIGHT_HOSTS}{{{_LIGHT}}}
.pyphi-leaf{{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:13px}}
.pyphi-head{{display:flex;align-items:baseline;justify-content:space-between;
gap:18px;padding:7px 14px;background:var(--pc-head);
border-bottom:1px solid var(--pc-soft)}}
.pyphi-title{{font-weight:600}}
.pyphi-badge{{background:var(--pc-badge-bg);color:var(--pc-badge-fg);
border-radius:6px;padding:2px 8px;font-size:12px;white-space:nowrap;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}}
.pyphi-body{{display:block}}
.pyphi-section{{padding:8px 14px;border-top:1px solid var(--pc-soft)}}
.pyphi-label{{font-weight:600;color:var(--pc-muted);font-size:10px;
text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px}}
table.pyphi-kv{{border-collapse:collapse;border-spacing:0;margin:0;width:auto}}
table.pyphi-kv th,table.pyphi-kv td{{border:0;background:none;padding:1.5px 0;
text-align:left;vertical-align:baseline;font-weight:400}}
table.pyphi-kv th.pyphi-k{{color:var(--pc-faint);padding-right:14px;
white-space:nowrap}}
.pyphi-v{{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}}
.pyphi-extra{{color:var(--pc-faint);margin-left:10px;font-size:12px}}
table.pyphi-table{{border-collapse:collapse;width:100%;font-size:12px}}
table.pyphi-table th{{text-align:left;color:var(--pc-muted);font-weight:600;
border-bottom:1px solid var(--pc-line);padding:3px 12px 3px 0}}
table.pyphi-table td{{text-align:left;padding:3px 12px 3px 0;
border-bottom:1px solid var(--pc-soft);white-space:pre;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}}
.pyphi-scroll{{max-height:26em;overflow:auto}}
.pyphi-more{{color:var(--pc-faint);font-size:12px;padding:3px 0}}
.pyphi-cause{{color:#D55C00}}
.pyphi-effect{{color:#009E73}}
table.pyphi-grid{{width:auto}}
table.pyphi-grid th,table.pyphi-grid td{{text-align:center;padding:3px 9px}}
table.pyphi-grid th:first-child,table.pyphi-grid td:first-child{{
text-align:right;color:var(--pc-muted);font-weight:600}}
</style>"""
# Grids with more rows than this scroll instead of rendering full height.
_GRID_SCROLL_ROWS = 16
_TONE_COLOR = {"cause": "#D55C00", "effect": "#009E73"}
def _tone_cls(tone: str | None) -> str:
"""Space-prefixed CSS class for a semantic tone, for appending to a class."""
return f" pyphi-{tone}" if tone in ("cause", "effect") else ""
def _tone_style(tone: str | None) -> str:
"""Inline ``color`` style for a tone, or empty string for no tone.
Used for table cells, where a class-based tone would lose to the more
specific ``table.pyphi-table th``/``td`` rules (and to some notebook
front-ends' own table CSS); an inline color wins regardless.
"""
color = _TONE_COLOR.get(tone or "")
return f' style="color:{color}"' if color else ""
# Trailing ``_x`` on a symbol denotes a subscript (e.g. ``φ_s`` -> φ-sub-s).
_SUBSCRIPT_RE = re.compile(r"([^\s_])_([A-Za-z0-9]+)")
def _sub(text: str) -> str:
"""Render a symbol subscript as HTML ``<sub>`` (e.g. ``φ_s`` -> ``φ<sub>s</sub>``).
Applied to already-escaped label/header text, not to values or grid cells.
"""
return _SUBSCRIPT_RE.sub(r"\1<sub>\2</sub>", text)
def _sub_title(text: str) -> str:
"""``title`` attribute spelling out a subscripted symbol, else empty string.
Converting the card to plain text discards ``<sub>`` structure, so the
underscore spelling (e.g. ``φ_s``) travels with the element as a hint.
"""
return f' title="{escape(text)}"' if _SUBSCRIPT_RE.search(text) else ""
def _value_html(value: object, extra: tuple[tuple[str, object], ...]) -> str:
parts = [f'<span class="pyphi-v">{escape(format_value(value))}</span>']
for name, val in extra:
parts.append(
f'<span class="pyphi-extra">{escape(name)} '
f"{escape(format_value(val))}</span>"
)
return "".join(parts)
def _kv_html(rows: tuple[Row, ...]) -> str:
trs = []
for row in rows:
label = (
f'<th scope="row" class="pyphi-k" style="width:var(--pc-kcol)"'
f"{_sub_title(row.label)}>{_sub(escape(row.label))}</th>"
)
val = _value_html(row.value, row.extra)
trs.append(
f'<tr>{label}<td class="pyphi-vcell{_tone_cls(row.tone)}">{val}</td></tr>'
)
return f'<table class="pyphi-kv">{"".join(trs)}</table>'
def _grid_cells(values: tuple[str, ...] | list[str], tag: str) -> str:
# Inline text-align so a matrix grid aligns in every notebook front-end
# (some, e.g. VS Code, override class-based table alignment); the first
# column is the row label (right-aligned), the rest center.
out = []
for i, v in enumerate(values):
align = "right" if i == 0 else "center"
out.append(
f'<{tag} style="text-align:{align};padding:3px 9px;white-space:pre">'
f"{escape(v)}</{tag}>"
)
return "".join(out)
def _formatted_rows(table: Table) -> list[tuple[str, ...]]:
"""Cell strings row by row, with numeric columns aligned on the decimal."""
columns = [
format_column([row[c] for row in table.rows]) for c in range(len(table.headers))
]
return list(zip(*columns, strict=True))
def _table_html(table: Table) -> str:
rows = _formatted_rows(table)
if table.grid:
headers = [format_value(h) for h in table.headers]
head = f"<tr>{_grid_cells(headers, 'th')}</tr>"
body = "".join(f"<tr>{_grid_cells(row, 'td')}</tr>" for row in rows)
grid_html = (
'<table class="pyphi-table pyphi-grid" '
'style="border-collapse:collapse;width:auto;margin:0">'
f"{head}{body}</table>"
)
# Small grids (e.g. cut grids) render inline; tall grids (e.g. TPMs)
# scroll and show an overflow indicator.
if len(table.rows) > _GRID_SCROLL_ROWS or table.overflow:
grid_html = f'<div class="pyphi-scroll">{grid_html}</div>'
if table.overflow:
grid_html += f'<div class="pyphi-more">… {table.overflow} more</div>'
return grid_html
tones = table.header_tones
head_cells = []
for i, h in enumerate(table.headers):
tone = tones[i] if i < len(tones) else None
head_cells.append(f"<th{_tone_style(tone)}>{_sub(escape(h))}</th>")
head = "".join(head_cells)
row_tones = table.row_tones
body_rows = []
for ri, row in enumerate(rows):
cell_tones = row_tones[ri] if ri < len(row_tones) else ()
cells = []
for ci, c in enumerate(row):
tone = cell_tones[ci] if ci < len(cell_tones) else None
cells.append(f"<td{_tone_style(tone)}>{escape(c)}</td>")
body_rows.append("<tr>" + "".join(cells) + "</tr>")
body = "".join(body_rows)
html = (
f'<div class="pyphi-scroll">'
f'<table class="pyphi-table"><tr>{head}</tr>{body}</table></div>'
)
if table.overflow:
html += f'<div class="pyphi-more">… {table.overflow} more</div>'
return html
def _section_html(section: Section) -> str:
parts = []
if section.label:
cls = f"pyphi-label{_tone_cls(section.tone)}"
parts.append(f'<div class="{cls}">{_sub(escape(section.label))}</div>')
if section.rows:
parts.append(_kv_html(section.rows))
for comp in section.body:
if isinstance(comp, Table):
parts.append(_table_html(comp))
elif isinstance(comp, Inline):
parts.append(comp.html or f"<pre>{escape(comp.text)}</pre>")
elif isinstance(comp, Row):
parts.append(_kv_html((comp,)))
elif isinstance(comp, Nested):
sub = comp.description
label = sub.compact or sub.title
parts.append(f'<span class="pyphi-extra">{escape(label)}</span>')
return f'<div class="pyphi-section">{"".join(parts)}</div>'
[docs]
def render(description: Description, verbosity: int) -> str: # noqa: ARG001
"""Render a description as an HTML-native styled card.
If the description has no sections, renders as a small inline leaf element
instead of a full card.
"""
if not description.sections:
text = description.compact or description.title
return _STYLE + f'<span class="pyphi-leaf">{escape(text)}</span>'
title_cls = f"pyphi-title{_tone_cls(description.tone)}"
head = [f'<span class="{title_cls}">{_sub(escape(description.title))}</span>']
if description.subtitle:
head.append(
f'<span class="pyphi-badge">{_sub(escape(description.subtitle))}</span>'
)
sections = "".join(_section_html(section) for section in description.sections)
return (
_STYLE
+ f'<div class="pyphi-card" style="--pc-kcol:{card_label_width(description)}ch">'
+ f'<div class="pyphi-head">{"".join(head)}</div>'
+ f'<div class="pyphi-body">{sections}</div></div>'
)