"""HTMLSet - A set subclass with HTML rendering capabilities."""
from __future__ import annotations
import html
import uuid
from collections.abc import Iterable
from enum import Enum
from typing import Any, Self
from animaid.css_types import (
AlignItems,
BorderValue,
ColorValue,
CSSValue,
JustifyContent,
SizeValue,
SpacingValue,
)
from animaid.html_object import HTMLObject
def _to_css(value: object) -> str:
"""Convert a value to its CSS string representation."""
if hasattr(value, "to_css"):
return str(value.to_css())
return str(value)
class SetDirection(Enum):
"""Direction in which set items are rendered."""
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
VERTICAL_REVERSE = "vertical-reverse"
HORIZONTAL_REVERSE = "horizontal-reverse"
GRID = "grid"
class SetFormat(Enum):
"""Format for displaying set items."""
PLAIN = "plain" # Just items in divs
BRACES = "braces" # {a, b, c} style
[docs]
class HTMLSet(HTMLObject, set):
"""A set subclass that renders as styled HTML.
HTMLSet behaves like a regular Python set but includes
methods for applying CSS styles and rendering to HTML.
All styling methods modify the object in-place and return self
for method chaining. Items are automatically deduplicated (set behavior).
Mutations trigger notifications for reactive updates.
Examples:
>>> s = HTMLSet({1, 2, 3})
>>> s.render()
'<span>{1, 2, 3}</span>'
>>> s.horizontal().pills().render()
'<div style="display: flex; ...">...</div>'
>>> HTMLSet([1, 1, 2, 2, 3]) # Duplicates removed
HTMLSet({1, 2, 3})
"""
_styles: dict[str, str]
_item_styles: dict[str, str]
_css_classes: list[str]
_item_classes: list[str]
_direction: SetDirection
_format: SetFormat
_grid_columns: int | None
_separator: str | None
_sorted: bool
_obs_id: str
def __init__(self, items: Iterable[Any] = (), **styles: str | CSSValue) -> None:
"""Initialize an HTMLSet.
Args:
items: The set items (any iterable, duplicates removed).
**styles: CSS styles for the container.
"""
super().__init__(items)
self._styles = {}
self._item_styles = {}
self._css_classes = []
self._item_classes = []
self._direction = SetDirection.HORIZONTAL
self._format = SetFormat.BRACES
self._grid_columns = None
self._separator = None
self._sorted = False
self._obs_id = str(uuid.uuid4())
for key, value in styles.items():
css_key = key.replace("_", "-")
self._styles[css_key] = _to_css(value)
def _notify(self) -> None:
"""Publish change notification via pypubsub."""
try:
from pubsub import pub
pub.sendMessage("animaid.changed", obs_id=self._obs_id)
except ImportError:
pass # pypubsub not installed
def _copy_with_settings(
self,
new_styles: dict[str, str] | None = None,
new_item_styles: dict[str, str] | None = None,
new_classes: list[str] | None = None,
new_item_classes: list[str] | None = None,
new_direction: SetDirection | None = None,
new_format: SetFormat | None = None,
new_grid_columns: int | None = None,
new_separator: str | None = None,
new_sorted: bool | None = None,
) -> Self:
"""Create a copy with modified settings.
This method is used internally for operations that must return
a new object (like set operations that return new sets).
"""
result = HTMLSet(self)
result._styles = self._styles.copy()
result._item_styles = self._item_styles.copy()
result._css_classes = self._css_classes.copy()
result._item_classes = self._item_classes.copy()
result._direction = self._direction
result._format = self._format
result._grid_columns = self._grid_columns
result._separator = self._separator
result._sorted = self._sorted
result._obs_id = self._obs_id # Preserve ID so updates still work
if new_styles:
result._styles.update(new_styles)
if new_item_styles:
result._item_styles.update(new_item_styles)
if new_classes:
result._css_classes.extend(new_classes)
if new_item_classes:
result._item_classes.extend(new_item_classes)
if new_direction is not None:
result._direction = new_direction
if new_format is not None:
result._format = new_format
if new_grid_columns is not None:
result._grid_columns = new_grid_columns
if new_separator is not None:
result._separator = new_separator
if new_sorted is not None:
result._sorted = new_sorted
return result # type: ignore[return-value]
# -------------------------------------------------------------------------
# HTMLObject interface
# -------------------------------------------------------------------------
[docs]
def styled(self, **styles: str | CSSValue) -> Self:
"""Apply additional container styles in-place.
Args:
**styles: CSS property-value pairs for the container.
Returns:
Self for method chaining.
"""
for key, value in styles.items():
css_key = key.replace("_", "-")
self._styles[css_key] = _to_css(value)
self._notify()
return self
[docs]
def add_class(self, *class_names: str) -> Self:
"""Add CSS classes on the container in-place.
Args:
*class_names: CSS class names to add.
Returns:
Self for method chaining.
"""
self._css_classes.extend(class_names)
self._notify()
return self
# -------------------------------------------------------------------------
# Format methods
# -------------------------------------------------------------------------
[docs]
def plain(self) -> Self:
"""Apply plain format (without brace decoration) in-place."""
self._format = SetFormat.PLAIN
self._notify()
return self
[docs]
def braces(self) -> Self:
"""Apply braces format (default) in-place."""
self._format = SetFormat.BRACES
self._notify()
return self
# -------------------------------------------------------------------------
# Ordering methods
# -------------------------------------------------------------------------
[docs]
def sorted(self) -> Self:
"""Apply sorted rendering order in-place."""
self._sorted = True
self._notify()
return self
[docs]
def unsorted(self) -> Self:
"""Apply iteration order (no sorting) in-place."""
self._sorted = False
self._notify()
return self
# -------------------------------------------------------------------------
# Direction methods
# -------------------------------------------------------------------------
[docs]
def vertical(self) -> Self:
"""Apply vertical layout in-place."""
self._direction = SetDirection.VERTICAL
self._notify()
return self
[docs]
def horizontal(self) -> Self:
"""Apply horizontal layout (default) in-place."""
self._direction = SetDirection.HORIZONTAL
self._notify()
return self
[docs]
def vertical_reverse(self) -> Self:
"""Apply reversed vertical layout in-place."""
self._direction = SetDirection.VERTICAL_REVERSE
self._notify()
return self
[docs]
def horizontal_reverse(self) -> Self:
"""Apply reversed horizontal layout in-place."""
self._direction = SetDirection.HORIZONTAL_REVERSE
self._notify()
return self
[docs]
def grid(self, columns: int = 3) -> Self:
"""Apply CSS grid layout in-place.
Args:
columns: Number of columns in the grid.
"""
self._direction = SetDirection.GRID
self._grid_columns = columns
self._notify()
return self
# -------------------------------------------------------------------------
# Spacing methods
# -------------------------------------------------------------------------
[docs]
def gap(self, value: SizeValue) -> Self:
"""Apply specified gap between items in-place."""
self._styles["gap"] = _to_css(value)
self._notify()
return self
[docs]
def padding(self, value: SpacingValue) -> Self:
"""Apply padding inside the container in-place."""
self._styles["padding"] = _to_css(value)
self._notify()
return self
[docs]
def margin(self, value: SpacingValue) -> Self:
"""Apply margin outside the container in-place."""
self._styles["margin"] = _to_css(value)
self._notify()
return self
[docs]
def item_padding(self, value: SpacingValue) -> Self:
"""Apply padding inside each item in-place."""
self._item_styles["padding"] = _to_css(value)
self._notify()
return self
[docs]
def item_margin(self, value: SpacingValue) -> Self:
"""Apply margin around each item in-place."""
self._item_styles["margin"] = _to_css(value)
self._notify()
return self
# -------------------------------------------------------------------------
# Border methods
# -------------------------------------------------------------------------
[docs]
def border(self, value: BorderValue) -> Self:
"""Apply border around the container in-place."""
self._styles["border"] = _to_css(value)
self._notify()
return self
[docs]
def border_radius(self, value: SizeValue) -> Self:
"""Apply rounded corners on the container in-place."""
self._styles["border-radius"] = _to_css(value)
self._notify()
return self
[docs]
def item_border(self, value: BorderValue) -> Self:
"""Apply border around each item in-place."""
self._item_styles["border"] = _to_css(value)
self._notify()
return self
[docs]
def item_border_radius(self, value: SizeValue) -> Self:
"""Apply rounded corners on each item in-place."""
self._item_styles["border-radius"] = _to_css(value)
self._notify()
return self
[docs]
def separator(self, value: BorderValue) -> Self:
"""Apply separator lines between items in-place."""
self._separator = _to_css(value)
self._notify()
return self
# -------------------------------------------------------------------------
# Background and color methods
# -------------------------------------------------------------------------
[docs]
def background(self, value: ColorValue) -> Self:
"""Apply background color on the container in-place."""
self._styles["background-color"] = _to_css(value)
self._notify()
return self
[docs]
def item_background(self, value: ColorValue) -> Self:
"""Apply background color on each item in-place."""
self._item_styles["background-color"] = _to_css(value)
self._notify()
return self
[docs]
def color(self, value: ColorValue) -> Self:
"""Apply text color in-place."""
self._styles["color"] = _to_css(value)
self._notify()
return self
# -------------------------------------------------------------------------
# Item class methods
# -------------------------------------------------------------------------
[docs]
def add_item_class(self, *class_names: str) -> Self:
"""Add CSS classes to each item in-place."""
self._item_classes.extend(class_names)
self._notify()
return self
# -------------------------------------------------------------------------
# Alignment methods
# -------------------------------------------------------------------------
[docs]
def align_items(self, value: AlignItems | str) -> Self:
"""Apply specified cross-axis alignment in-place."""
self._styles["align-items"] = _to_css(value)
self._notify()
return self
[docs]
def justify_content(self, value: JustifyContent | str) -> Self:
"""Apply specified main-axis alignment in-place."""
self._styles["justify-content"] = _to_css(value)
self._notify()
return self
[docs]
def center(self) -> Self:
"""Apply centered alignment on both axes in-place."""
self._styles["align-items"] = "center"
self._styles["justify-content"] = "center"
self._notify()
return self
# -------------------------------------------------------------------------
# Size methods
# -------------------------------------------------------------------------
[docs]
def width(self, value: SizeValue) -> Self:
"""Apply specified width in-place."""
self._styles["width"] = _to_css(value)
self._notify()
return self
[docs]
def height(self, value: SizeValue) -> Self:
"""Apply specified height in-place."""
self._styles["height"] = _to_css(value)
self._notify()
return self
# -------------------------------------------------------------------------
# Style Presets
# -------------------------------------------------------------------------
[docs]
def pills(self) -> Self:
"""Apply pill/badge style in-place."""
self._format = SetFormat.PLAIN
self._direction = SetDirection.HORIZONTAL
self._styles["gap"] = "8px"
self._styles["flex-wrap"] = "wrap"
self._item_styles["padding"] = "6px 14px"
self._item_styles["border-radius"] = "20px"
self._item_styles["background-color"] = "#e0e0e0"
self._notify()
return self
[docs]
def inline(self) -> Self:
"""Apply inline style in-place."""
self._format = SetFormat.PLAIN
self._direction = SetDirection.HORIZONTAL
self._styles["gap"] = "8px"
self._styles["flex-wrap"] = "wrap"
self._notify()
return self
[docs]
def spaced(self) -> Self:
"""Apply generous spacing style in-place."""
self._styles["gap"] = "16px"
self._item_styles["padding"] = "8px"
self._notify()
return self
[docs]
def compact(self) -> Self:
"""Apply minimal spacing style in-place."""
self._styles["gap"] = "4px"
self._item_styles["padding"] = "2px"
self._notify()
return self
# -------------------------------------------------------------------------
# Rendering
# -------------------------------------------------------------------------
def _get_items(self) -> list[Any]:
"""Get items in render order."""
items = list(self)
if self._sorted:
try:
items = sorted(items, key=str) # type: ignore[assignment]
except TypeError:
# If items can't be sorted, keep original order
pass
return items
def _render_item(self, item: Any) -> str:
"""Render a single item to HTML."""
if isinstance(item, HTMLObject):
return item.render()
elif isinstance(item, str):
return html.escape(item)
else:
return html.escape(str(item))
def _get_container_styles(self) -> dict[str, str]:
"""Build the complete container styles including layout."""
styles = self._styles.copy()
if self._format != SetFormat.BRACES:
# Flexbox/grid layout for non-brace formats
if self._direction == SetDirection.HORIZONTAL:
styles.setdefault("display", "inline-flex")
styles.setdefault("flex-direction", "row")
styles.setdefault("align-items", "center")
elif self._direction == SetDirection.HORIZONTAL_REVERSE:
styles.setdefault("display", "inline-flex")
styles.setdefault("flex-direction", "row-reverse")
styles.setdefault("align-items", "center")
elif self._direction == SetDirection.VERTICAL:
styles.setdefault("display", "inline-flex")
styles.setdefault("flex-direction", "column")
elif self._direction == SetDirection.VERTICAL_REVERSE:
styles.setdefault("display", "inline-flex")
styles.setdefault("flex-direction", "column-reverse")
elif self._direction == SetDirection.GRID:
styles.setdefault("display", "inline-grid")
cols = self._grid_columns or 3
styles.setdefault("grid-template-columns", f"repeat({cols}, 1fr)")
return styles
def _build_item_style_string(self, index: int, total: int) -> str:
"""Build style string for an item, including separators."""
styles = self._item_styles.copy()
if self._separator:
is_horizontal = self._direction in (
SetDirection.HORIZONTAL,
SetDirection.HORIZONTAL_REVERSE,
)
is_last = index == total - 1
if not is_last:
if is_horizontal:
styles["border-right"] = self._separator
else:
styles["border-bottom"] = self._separator
if not styles:
return ""
return "; ".join(f"{k}: {v}" for k, v in styles.items())
def _build_item_attributes(self, index: int, total: int) -> str:
"""Build complete attribute string for an item."""
parts = []
if self._item_classes:
class_str = " ".join(self._item_classes)
parts.append(f'class="{class_str}"')
style_str = self._build_item_style_string(index, total)
if style_str:
parts.append(f'style="{style_str}"')
return " ".join(parts)
def _render_braces(self) -> str:
"""Render with braces style: {a, b, c}."""
items = self._get_items()
if len(items) == 0:
return "<span>{}</span>"
items_html = []
for item in items:
items_html.append(self._render_item(item))
content = ", ".join(items_html)
attrs = self._build_attributes()
if attrs:
return f"<span {attrs}>{{{content}}}</span>"
return f"<span>{{{content}}}</span>"
def _render_plain(self) -> str:
"""Render as plain items in divs."""
items = self._get_items()
if len(items) == 0:
attrs = self._build_attributes()
if attrs:
return f"<div {attrs}></div>"
return "<div></div>"
# Build container styles
container_styles = self._get_container_styles()
self._styles = container_styles
# Build container opening tag
attrs = self._build_attributes()
if attrs:
container_open = f"<div {attrs}>"
else:
container_open = "<div>"
# Render items with commas between them
total = len(items)
items_html = []
for i, item in enumerate(items):
item_content = self._render_item(item)
item_attrs = self._build_item_attributes(i, total)
if item_attrs:
items_html.append(f"<span {item_attrs}>{item_content}</span>")
else:
items_html.append(f"<span>{item_content}</span>")
# Add comma separator after each item except the last
if i < total - 1:
items_html.append("<span>, </span>")
return f"{container_open}{''.join(items_html)}</div>"
[docs]
def render(self) -> str:
"""Return HTML representation of this set.
Returns:
A string containing valid HTML.
"""
if self._format == SetFormat.BRACES:
return self._render_braces()
else: # PLAIN
return self._render_plain()
# -------------------------------------------------------------------------
# Set operation overrides (non-mutating, return new sets)
# -------------------------------------------------------------------------
[docs]
def union(self, *others: Iterable[Any]) -> Self:
"""Return union with other sets, preserving settings."""
result = HTMLSet(set.union(self, *others))
result._styles = self._styles.copy()
result._item_styles = self._item_styles.copy()
result._css_classes = self._css_classes.copy()
result._item_classes = self._item_classes.copy()
result._direction = self._direction
result._format = self._format
result._grid_columns = self._grid_columns
result._separator = self._separator
result._sorted = self._sorted
result._obs_id = self._obs_id
return result # type: ignore[return-value]
[docs]
def intersection(self, *others: Iterable[Any]) -> Self:
"""Return intersection with other sets, preserving settings."""
result = HTMLSet(set.intersection(self, *others))
result._styles = self._styles.copy()
result._item_styles = self._item_styles.copy()
result._css_classes = self._css_classes.copy()
result._item_classes = self._item_classes.copy()
result._direction = self._direction
result._format = self._format
result._grid_columns = self._grid_columns
result._separator = self._separator
result._sorted = self._sorted
result._obs_id = self._obs_id
return result # type: ignore[return-value]
[docs]
def difference(self, *others: Iterable[Any]) -> Self:
"""Return difference with other sets, preserving settings."""
result = HTMLSet(set.difference(self, *others))
result._styles = self._styles.copy()
result._item_styles = self._item_styles.copy()
result._css_classes = self._css_classes.copy()
result._item_classes = self._item_classes.copy()
result._direction = self._direction
result._format = self._format
result._grid_columns = self._grid_columns
result._separator = self._separator
result._sorted = self._sorted
result._obs_id = self._obs_id
return result # type: ignore[return-value]
[docs]
def symmetric_difference(self, other: Iterable[Any]) -> Self:
"""Return symmetric difference with other set, preserving settings."""
result = HTMLSet(set.symmetric_difference(self, other))
result._styles = self._styles.copy()
result._item_styles = self._item_styles.copy()
result._css_classes = self._css_classes.copy()
result._item_classes = self._item_classes.copy()
result._direction = self._direction
result._format = self._format
result._grid_columns = self._grid_columns
result._separator = self._separator
result._sorted = self._sorted
result._obs_id = self._obs_id
return result # type: ignore[return-value]
def __or__(self, other: Iterable[Any]) -> Self:
"""Union operator |."""
return self.union(other)
def __and__(self, other: Iterable[Any]) -> Self:
"""Intersection operator &."""
return self.intersection(other)
def __sub__(self, other: Iterable[Any]) -> Self:
"""Difference operator -."""
return self.difference(other)
def __xor__(self, other: Iterable[Any]) -> Self:
"""Symmetric difference operator ^."""
return self.symmetric_difference(other)
# -------------------------------------------------------------------------
# Observable mutating methods
# -------------------------------------------------------------------------
[docs]
def add(self, item: Any) -> None:
"""Add item, notifying observers."""
super().add(item)
self._notify()
[docs]
def discard(self, item: Any) -> None:
"""Discard item, notifying observers."""
super().discard(item)
self._notify()
[docs]
def remove(self, item: Any) -> None:
"""Remove item, notifying observers."""
super().remove(item)
self._notify()
[docs]
def pop(self) -> Any:
"""Pop item, notifying observers."""
result = super().pop()
self._notify()
return result
[docs]
def clear(self) -> None:
"""Clear set, notifying observers."""
super().clear()
self._notify()
[docs]
def update(self, *others: Iterable[Any]) -> None:
"""Update set, notifying observers."""
super().update(*others)
self._notify()
[docs]
def intersection_update(self, *others: Iterable[Any]) -> None:
"""Intersection update, notifying observers."""
super().intersection_update(*others)
self._notify()
[docs]
def difference_update(self, *others: Iterable[Any]) -> None:
"""Difference update, notifying observers."""
super().difference_update(*others)
self._notify()
[docs]
def symmetric_difference_update(self, other: Iterable[Any]) -> None:
"""Symmetric difference update, notifying observers."""
super().symmetric_difference_update(other)
self._notify()
def __repr__(self) -> str:
"""Return a detailed representation for debugging."""
items_repr = set.__repr__(set(self))
extras = []
if self._format != SetFormat.BRACES:
extras.append(f"format={self._format.value}")
if self._direction != SetDirection.HORIZONTAL:
extras.append(f"direction={self._direction.value}")
if self._sorted:
extras.append("sorted=True")
if self._styles:
extras.append(f"styles={self._styles}")
if extras:
return f"HTMLSet({items_repr}, {', '.join(extras)})"
return f"HTMLSet({items_repr})"