"""HTMLList - A list subclass with HTML rendering capabilities."""
from __future__ import annotations
import html
import uuid
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 ListDirection(Enum):
"""Direction in which list items are rendered."""
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
VERTICAL_REVERSE = "vertical-reverse"
HORIZONTAL_REVERSE = "horizontal-reverse"
GRID = "grid"
class ListType(Enum):
"""Type of HTML list structure."""
UNORDERED = "ul" # <ul><li>...</li></ul>
ORDERED = "ol" # <ol><li>...</li></ol>
PLAIN = "div" # <div><div>...</div></div> with flexbox
[docs]
class HTMLList(HTMLObject, list):
"""A list subclass that renders as styled HTML.
HTMLList behaves like a regular Python list 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. Supports vertical, horizontal, and grid layouts.
Examples:
>>> items = HTMLList(["Apple", "Banana", "Cherry"])
>>> items.render()
'<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>'
>>> items.horizontal().gap("10px").render()
'<div style="display: flex; flex-direction: row; gap: 10px">...</div>'
>>> HTMLList([1, 2, 3]).ordered().render()
'<ol><li>1</li><li>2</li><li>3</li></ol>'
"""
_styles: dict[str, str]
_item_styles: dict[str, str]
_css_classes: list[str]
_item_classes: list[str]
_direction: ListDirection
_list_type: ListType
_grid_columns: int | None
_separator: str | None
_obs_id: str
def __init__(
self, items: list[Any] | None = None, **styles: str | CSSValue
) -> None:
"""Initialize an HTMLList.
Args:
items: Initial list items.
**styles: CSS styles for the container (underscores to hyphens).
Accepts both strings and CSS type objects (Color, Size, etc.)
"""
super().__init__(items or [])
self._styles = {}
self._item_styles = {}
self._css_classes = []
self._item_classes = []
self._direction = ListDirection.VERTICAL
self._list_type = ListType.UNORDERED
self._grid_columns = None
self._separator = None
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: ListDirection | None = None,
new_list_type: ListType | None = None,
new_grid_columns: int | None = None,
new_separator: str | None = None,
) -> Self:
"""Create a copy with modified settings.
This method is used internally for operations that must return
a new object (like slicing or concatenation).
Args:
new_styles: Container styles to merge.
new_item_styles: Item styles to merge.
new_classes: Classes to add to container.
new_item_classes: Classes to add to items.
new_direction: New layout direction.
new_list_type: New list type.
new_grid_columns: Grid column count.
new_separator: Separator style between items.
Returns:
A new HTMLList with combined settings.
"""
result = HTMLList(list(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._list_type = self._list_type
result._grid_columns = self._grid_columns
result._separator = self._separator
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_list_type is not None:
result._list_type = new_list_type
if new_grid_columns is not None:
result._grid_columns = new_grid_columns
if new_separator is not None:
result._separator = new_separator
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.
Accepts both strings and CSS type objects (Color, Size, etc.)
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
# -------------------------------------------------------------------------
# Direction methods
# -------------------------------------------------------------------------
[docs]
def vertical(self) -> Self:
"""Apply vertical layout (default) in-place.
Items are stacked top to bottom.
"""
self._direction = ListDirection.VERTICAL
self._notify()
return self
[docs]
def horizontal(self) -> Self:
"""Apply horizontal layout in-place.
Items are arranged left to right using flexbox.
"""
self._direction = ListDirection.HORIZONTAL
self._list_type = ListType.PLAIN
self._notify()
return self
[docs]
def vertical_reverse(self) -> Self:
"""Apply reversed vertical layout in-place.
Items are stacked bottom to top.
"""
self._direction = ListDirection.VERTICAL_REVERSE
self._list_type = ListType.PLAIN
self._notify()
return self
[docs]
def horizontal_reverse(self) -> Self:
"""Apply reversed horizontal layout in-place.
Items are arranged right to left.
"""
self._direction = ListDirection.HORIZONTAL_REVERSE
self._list_type = ListType.PLAIN
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.
Returns:
Self for method chaining.
"""
self._direction = ListDirection.GRID
self._list_type = ListType.PLAIN
self._grid_columns = columns
self._notify()
return self
# -------------------------------------------------------------------------
# List type methods
# -------------------------------------------------------------------------
[docs]
def ordered(self) -> Self:
"""Apply ordered list (<ol>) format in-place."""
self._list_type = ListType.ORDERED
self._notify()
return self
[docs]
def unordered(self) -> Self:
"""Apply unordered list (<ul>) format in-place."""
self._list_type = ListType.UNORDERED
self._notify()
return self
[docs]
def plain(self) -> Self:
"""Apply plain div container (no bullets/numbers) in-place."""
self._list_type = ListType.PLAIN
self._notify()
return self
# -------------------------------------------------------------------------
# Spacing methods
# -------------------------------------------------------------------------
[docs]
def gap(self, value: SizeValue) -> Self:
"""Apply specified gap between items in-place.
Args:
value: CSS gap value (e.g., "10px", Size.px(10), Size.rem(1)).
"""
self._styles["gap"] = _to_css(value)
self._notify()
return self
[docs]
def padding(self, value: SpacingValue) -> Self:
"""Apply padding inside the container in-place.
Args:
value: CSS padding value (e.g., "10px", Size.px(10)).
"""
self._styles["padding"] = _to_css(value)
self._notify()
return self
[docs]
def margin(self, value: SpacingValue) -> Self:
"""Apply margin outside the container in-place.
Args:
value: CSS margin value (e.g., "10px", Size.px(10), Spacing.all(10)).
"""
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.
Args:
value: CSS padding value for items.
"""
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.
Args:
value: CSS margin value for items.
"""
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.
Args:
value: CSS border value (e.g., "1px solid black", Border.solid()).
"""
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.
Args:
value: CSS border-radius value (e.g., "5px", Size.px(5)).
"""
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.
Args:
value: CSS border value for items.
"""
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.
Args:
value: CSS border-radius value for items.
"""
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.
Unlike item_border, this only adds borders between items,
not on the outer edges.
Args:
value: CSS border value for separators.
"""
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.
Args:
value: CSS color value (e.g., "white", Color.white, Color.hex("#fff")).
"""
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.
Args:
value: CSS color value.
"""
self._item_styles["background-color"] = _to_css(value)
self._notify()
return self
[docs]
def color(self, value: ColorValue) -> Self:
"""Apply text color in-place.
Args:
value: CSS color value (e.g., "black", Color.black).
"""
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.
Args:
*class_names: CSS class names to add to items.
"""
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.
Args:
value: CSS align-items value (e.g., "center", AlignItems.CENTER).
"""
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.
Args:
value: CSS justify-content value (e.g., "space-between").
"""
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.
Args:
value: CSS width value (e.g., "100px", Size.px(100), Size.percent(50)).
"""
self._styles["width"] = _to_css(value)
self._notify()
return self
[docs]
def height(self, value: SizeValue) -> Self:
"""Apply specified height in-place.
Args:
value: CSS height value (e.g., "200px", Size.px(200), Size.vh(100)).
"""
self._styles["height"] = _to_css(value)
self._notify()
return self
[docs]
def max_width(self, value: SizeValue) -> Self:
"""Apply maximum width constraint in-place.
Args:
value: CSS max-width value (e.g., "800px", Size.px(800)).
"""
self._styles["max-width"] = _to_css(value)
self._notify()
return self
# -------------------------------------------------------------------------
# Style Presets (beginner-friendly)
# -------------------------------------------------------------------------
[docs]
def cards(self) -> Self:
"""Apply card list style with shadows and spacing in-place.
Creates a visually appealing list where each item looks like a card.
"""
self._list_type = ListType.PLAIN
self._direction = ListDirection.HORIZONTAL
self._styles["gap"] = "16px"
self._styles["flex-wrap"] = "wrap"
self._item_styles["padding"] = "16px"
self._item_styles["border"] = "1px solid #e0e0e0"
self._item_styles["border-radius"] = "8px"
self._item_styles["background-color"] = "white"
self._notify()
return self
[docs]
def pills(self) -> Self:
"""Apply pill/badge style in-place.
Creates a horizontal list of pill-shaped items.
"""
self._list_type = ListType.PLAIN
self._direction = ListDirection.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.
Creates a simple inline list separated by spacing.
"""
self._list_type = ListType.PLAIN
self._direction = ListDirection.HORIZONTAL
self._styles["gap"] = "8px"
self._styles["flex-wrap"] = "wrap"
self._notify()
return self
[docs]
def numbered(self) -> Self:
"""Apply numbered list style in-place."""
self._list_type = ListType.ORDERED
self._styles["padding-left"] = "24px"
self._item_styles["padding"] = "4px 0"
self._notify()
return self
[docs]
def bulleted(self) -> Self:
"""Apply bulleted list style in-place."""
self._list_type = ListType.UNORDERED
self._styles["padding-left"] = "24px"
self._item_styles["padding"] = "4px 0"
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 _render_item(self, item: Any) -> str:
"""Render a single item to HTML.
Args:
item: The item to render.
Returns:
HTML string for the item.
"""
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()
# Add layout-specific styles
if self._list_type == ListType.PLAIN:
if self._direction == ListDirection.HORIZONTAL:
styles.setdefault("display", "flex")
styles.setdefault("flex-direction", "row")
styles.setdefault("flex-wrap", "wrap")
elif self._direction == ListDirection.HORIZONTAL_REVERSE:
styles.setdefault("display", "flex")
styles.setdefault("flex-direction", "row-reverse")
styles.setdefault("flex-wrap", "wrap")
elif self._direction == ListDirection.VERTICAL:
styles.setdefault("display", "flex")
styles.setdefault("flex-direction", "column")
elif self._direction == ListDirection.VERTICAL_REVERSE:
styles.setdefault("display", "flex")
styles.setdefault("flex-direction", "column-reverse")
elif self._direction == ListDirection.GRID:
styles.setdefault("display", "grid")
cols = self._grid_columns or 3
styles.setdefault("grid-template-columns", f"repeat({cols}, 1fr)")
# Remove list styling for ul/ol if needed
if self._list_type in (ListType.UNORDERED, ListType.ORDERED):
if self._direction != ListDirection.VERTICAL:
styles.setdefault("display", "flex")
styles.setdefault("list-style", "none")
styles.setdefault("padding-left", "0")
if self._direction == ListDirection.HORIZONTAL:
styles.setdefault("flex-direction", "row")
elif self._direction == ListDirection.HORIZONTAL_REVERSE:
styles.setdefault("flex-direction", "row-reverse")
return styles
def _build_item_style_string(self, index: int, total: int) -> str:
"""Build style string for an item, including separators.
Args:
index: Item index (0-based).
total: Total number of items.
Returns:
CSS style attribute value.
"""
styles = self._item_styles.copy()
# Add separator styles
if self._separator:
is_horizontal = self._direction in (
ListDirection.HORIZONTAL,
ListDirection.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)
[docs]
def render(self) -> str:
"""Return HTML representation of this list.
Returns:
A string containing valid HTML.
"""
if len(self) == 0:
# Empty list
container_tag = self._list_type.value
attrs = self._build_attributes()
if attrs:
return f"<{container_tag} {attrs}></{container_tag}>"
return f"<{container_tag}></{container_tag}>"
# Build container styles
container_styles = self._get_container_styles()
self._styles = container_styles
# Determine tags
container_tag = self._list_type.value
uses_list_item = self._list_type in (ListType.UNORDERED, ListType.ORDERED)
item_tag = "li" if uses_list_item else "div"
# Build container opening tag
attrs = self._build_attributes()
if attrs:
container_open = f"<{container_tag} {attrs}>"
else:
container_open = f"<{container_tag}>"
# Render items
total = len(self)
items_html = []
for i, item in enumerate(self):
item_content = self._render_item(item)
item_attrs = self._build_item_attributes(i, total)
if item_attrs:
tag_html = f"<{item_tag} {item_attrs}>{item_content}</{item_tag}>"
items_html.append(tag_html)
else:
items_html.append(f"<{item_tag}>{item_content}</{item_tag}>")
return f"{container_open}{''.join(items_html)}</{container_tag}>"
# -------------------------------------------------------------------------
# List operation overrides
# -------------------------------------------------------------------------
def __add__(self, other: list[Any]) -> Self:
"""Concatenate lists, preserving settings."""
result = HTMLList(list.__add__(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._list_type = self._list_type
result._grid_columns = self._grid_columns
result._separator = self._separator
result._obs_id = self._obs_id
return result # type: ignore[return-value]
def __getitem__(self, key: Any) -> Any:
"""Get item or slice.
Single index returns the item itself.
Slice returns a new HTMLList with settings preserved.
"""
result = list.__getitem__(self, key)
if isinstance(key, slice):
new_list = HTMLList(result)
new_list._styles = self._styles.copy()
new_list._item_styles = self._item_styles.copy()
new_list._css_classes = self._css_classes.copy()
new_list._item_classes = self._item_classes.copy()
new_list._direction = self._direction
new_list._list_type = self._list_type
new_list._grid_columns = self._grid_columns
new_list._separator = self._separator
new_list._obs_id = self._obs_id
return new_list
return result
def __setitem__(self, key: Any, value: Any) -> None:
"""Set item, notifying observers."""
super().__setitem__(key, value)
self._notify()
def __delitem__(self, key: Any) -> None:
"""Delete item, notifying observers."""
super().__delitem__(key)
self._notify()
[docs]
def append(self, item: Any) -> None:
"""Append item, notifying observers."""
super().append(item)
self._notify()
[docs]
def extend(self, items: Any) -> None:
"""Extend list, notifying observers."""
super().extend(items)
self._notify()
[docs]
def insert(self, index: Any, item: Any) -> None:
"""Insert item, notifying observers."""
super().insert(index, item)
self._notify()
[docs]
def remove(self, item: Any) -> None:
"""Remove item, notifying observers."""
super().remove(item)
self._notify()
[docs]
def pop(self, index: Any = -1) -> Any:
"""Pop item, notifying observers."""
result = super().pop(index)
self._notify()
return result
[docs]
def clear(self) -> None:
"""Clear list, notifying observers."""
super().clear()
self._notify()
[docs]
def sort(self, *, key: Any = None, reverse: bool = False) -> None:
"""Sort list, notifying observers."""
super().sort(key=key, reverse=reverse)
self._notify()
[docs]
def reverse(self) -> None:
"""Reverse list, notifying observers."""
super().reverse()
self._notify()
def __repr__(self) -> str:
"""Return a detailed representation for debugging."""
items_repr = list.__repr__(self)
extras = []
if self._direction != ListDirection.VERTICAL:
extras.append(f"direction={self._direction.value}")
if self._list_type != ListType.UNORDERED:
extras.append(f"type={self._list_type.value}")
if self._styles:
extras.append(f"styles={self._styles}")
if extras:
return f"HTMLList({items_repr}, {', '.join(extras)})"
return f"HTMLList({items_repr})"