[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/pythonnative/pythonnative/v0.43.1/src/pythonnative/style.py [Back]  [Original]

"""StyleSheet, typed `Style`, style resolution, and theming.

PythonNative ships a single, fully-typed [`Style`][pythonnative.Style]
TypedDict that enumerates every supported style property and constrains
enum-shaped values via [`typing.Literal`][typing.Literal]. The TypedDict
gives editors and type-checkers (mypy, pyright) full autocomplete and
validation: a typo such as ``flex_direction="collumn"`` is now a static
error, not a silent runtime no-op.

Style values remain plain dicts at runtime so they are trivial to
compose, diff, and store. Properties unrecognized by a platform handler
are still ignored, so third-party handlers may extend the palette
without modifying core types.

The runtime helpers ([`resolve_style`][pythonnative.style.resolve_style],
[`StyleSheet`][pythonnative.StyleSheet]) accept either a ``Style``
TypedDict, a regular ``Dict[str, Any]`` (for forward-compat or
unrestricted use), or a list of either kind of dict, and always
return a fresh, flat dict.

Example:
    ```python
    import pythonnative as pn

    styles = pn.StyleSheet.create(
        title=pn.style(font_size=24, bold=True, color="#333"),
        container=pn.style(padding=16, spacing=12),
    )

    pn.Column(
        pn.Text("Hello", style=styles["title"]),
        style=styles["container"],
    )
    ```
"""

import dataclasses
import difflib
from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union, get_args

from . import diagnostics
from .hooks import Context, create_context, use_color_scheme, use_context

# ======================================================================
# Atomic value types
# ======================================================================

Color = str
"""Color value: ``"#RRGGBB"``, ``"#AARRGGBB"``, or any string a platform
handler recognizes (e.g., ``"red"``). Stored verbatim and parsed by the
handler at apply-time, so palettes from third-party libraries pass through
unchanged."""

Dimension = Union[int, float, str]
"""A length value. Numbers are points/dp; strings ending in ``"%"`` are
parent-relative percentages."""


class EdgeInsets(TypedDict, total=False):
    """Per-edge spacing values used for ``padding`` and ``margin``.

    Mirrors React Native's ``EdgeInsets`` shape with PythonNative's
    convenience aliases. Any subset of keys may be supplied.
    """

    top: Dimension
    right: Dimension
    bottom: Dimension
    left: Dimension
    horizontal: Dimension
    vertical: Dimension
    all: Dimension


EdgeValue = Union[Dimension, EdgeInsets]
"""Padding/margin value: a uniform number, a ``"%"`` string, or an
[`EdgeInsets`][pythonnative.EdgeInsets] dict."""


class AccessibilityState(TypedDict, total=False):
    """Current widget state exposed to assistive technology.

    Passed via the ``accessibility_state=`` prop on interactive
    components. All keys are optional; omitted keys are treated as
    unset (not ``False``).

    Attributes:
        disabled: The widget is visible but not interactive.
        selected: The widget is currently selected (e.g. the active
            tab).
        checked: Toggle/checkbox state. ``"mixed"`` represents a
            tri-state checkbox.
        busy: The widget is loading or otherwise temporarily busy.
        expanded: A disclosure/accordion is currently expanded.
    """

    disabled: bool
    selected: bool
    checked: Union[bool, Literal["mixed"]]
    busy: bool
    expanded: bool


class ShadowOffset(TypedDict):
    """Shadow displacement in points."""

    width: float
    height: float


ShadowOffsetValue = Union[ShadowOffset, Tuple[float, float], List[float]]
"""``shadow_offset`` / ``text_shadow_offset`` value: an
[`ShadowOffset`][pythonnative.ShadowOffset] dict or an ``(x, y)`` pair."""


# ----------------------------------------------------------------------
# Transforms
# ----------------------------------------------------------------------


class TransformRotate(TypedDict):
    """Rotation transform in degrees (numeric) or with explicit unit suffix."""

    rotate: Union[float, str]


class TransformScale(TypedDict):
    """Uniform scale transform."""

    scale: float


class TransformScaleX(TypedDict):
    """Horizontal-only scale transform."""

    scale_x: float


class TransformScaleY(TypedDict):
    """Vertical-only scale transform."""

    scale_y: float


class TransformTranslate(TypedDict, total=False):
    """Translation transform along one or both axes."""

    translate_x: float
    translate_y: float


TransformEntry = Union[
    TransformRotate,
    TransformScale,
    TransformScaleX,
    TransformScaleY,
    TransformTranslate,
    Dict[str, Any],
]
"""A single transform operation."""

TransformSpec = Union[TransformEntry, List[TransformEntry]]
"""``transform`` style value: a single operation or an ordered list."""


# ----------------------------------------------------------------------
# Enum-shaped style values
# ----------------------------------------------------------------------

FlexDirection = Literal["row", "column", "row_reverse", "column_reverse"]
FlexWrap = Literal["nowrap", "wrap", "wrap_reverse"]
AlignContent = Literal[
    "flex_start",
    "center",
    "flex_end",
    "stretch",
    "space_between",
    "space_around",
    "space_evenly",
]
LayoutDirection = Literal["ltr", "rtl"]
JustifyContent = Literal[
    "flex_start",
    "center",
    "flex_end",
    "space_between",
    "space_around",
    "space_evenly",
    "start",
    "leading",
    "top",
    "end",
    "trailing",
    "bottom",
]
AlignItems = Literal[
    "stretch",
    "flex_start",
    "center",
    "flex_end",
    "baseline",
    "auto",
    "start",
    "leading",
    "top",
    "end",
    "trailing",
    "bottom",
    "fill",
]
AlignSelf = AlignItems
Position = Literal["relative", "absolute"]
Display = Literal["flex", "none"]
"""``display`` style value. ``"none"`` removes the node and its subtree
from layout entirely (no size, gap, or margin contribution; every frame
in the subtree is ``(0, 0, 0, 0)``)."""
Overflow = Literal["visible", "hidden", "scroll"]
TextAlign = Literal["left", "center", "right", "justify", "start", "end"]
TextDecoration = Literal["none", "underline", "line_through"]
TextTransform = Literal["none", "uppercase", "lowercase", "capitalize"]
"""``text_transform`` style value. Applied in Python before the string
reaches the native label, so measurement and rendering agree."""
MarginValue = Union[Dimension, Literal["auto"]]
"""A margin length: points, a ``"%"`` string, or ``"auto"`` to absorb free space."""
FontWeight = Literal[
    "normal",
    "bold",
    "100",
    "200",
    "300",
    "400",
    "500",
    "600",
    "700",
    "800",
    "900",
]
ScaleType = Literal["cover", "contain", "stretch", "center"]
KeyboardType = Literal[
    "default",
    "email_address",
    "number_pad",
    "decimal_pad",
    "phone_pad",
    "url",
]
AutoCapitalize = Literal["none", "sentences", "words", "characters"]
ReturnKeyType = Literal["default", "done", "go", "next", "send", "search"]
PointerEvents = Literal["auto", "none", "box_none", "box_only"]
"""Touch-handling mode for a view and its subtree:

- ``"auto"``: the view and its children receive touches (default).
- ``"none"``: neither the view nor its children receive touches;
  touches pass through to whatever is underneath.
- ``"box_none"``: the view itself ignores touches but its children
  still receive them (decorative overlays with interactive content).
- ``"box_only"``: the view receives touches but its children do not.
"""


# ======================================================================
# Style TypedDict
# ======================================================================


class Style(TypedDict, total=False):
    """Statically-typed style dictionary.

    Lists every style property recognized by the built-in components
    plus their layout engine, with enum-shaped values constrained via
    [`Literal`][typing.Literal]. ``Style`` is a `total=False` TypedDict
    so any subset of keys is valid at construction time.

    Custom native components may accept additional, unlisted keys;
    they are ignored by the built-in handlers but flow through the
    reconciler unmodified, so third-party handlers can read them.

    Example:
        ```python
        import pythonnative as pn

        title: pn.Style = {
            "font_size": 24,
            "color": "#0A84FF",
            "text_align": "center",
        }

        pn.Text("Hello", style=title)
        ```
    """

    # --- Layout: sizing ---
    width: Dimension
    height: Dimension
    min_width: Dimension
    max_width: Dimension
    min_height: Dimension
    max_height: Dimension
    aspect_ratio: float

    # --- Layout: flex ---
    flex: float
    flex_grow: float
    flex_shrink: float
    flex_basis: Dimension
    flex_direction: FlexDirection
    flex_wrap: FlexWrap
    justify_content: JustifyContent
    align_items: AlignItems
    align_self: AlignSelf
    align_content: AlignContent
    direction: LayoutDirection
    display: Display

    # --- Layout: position ---
    position: Position
    top: Dimension
    right: Dimension
    bottom: Dimension
    left: Dimension
    start: Dimension
    end: Dimension

    # --- Layout: spacing ---
    padding: EdgeValue
    padding_top: Dimension
    padding_bottom: Dimension
    padding_left: Dimension
    padding_right: Dimension
    padding_start: Dimension
    padding_end: Dimension
    padding_horizontal: Dimension
    padding_vertical: Dimension
    margin: Union[MarginValue, EdgeInsets]
    margin_top: MarginValue
    margin_bottom: MarginValue
    margin_left: MarginValue
    margin_right: MarginValue
    margin_start: MarginValue
    margin_end: MarginValue
    margin_horizontal: MarginValue
    margin_vertical: MarginValue
    spacing: float
    gap: float
    row_gap: float
    column_gap: float

    # --- Visual: clipping & overflow ---
    overflow: Overflow

    # --- Visual: colors ---
    background_color: Color
    color: Color
    border_color: Color
    placeholder_color: Color
    tint_color: Color

    # --- Visual: borders ---
    border_width: float
    border_radius: float
    border_top_left_radius: float
    border_top_right_radius: float
    border_bottom_left_radius: float
    border_bottom_right_radius: float
    border_top_width: float
    border_right_width: float
    border_bottom_width: float
    border_left_width: float
    border_top_color: Color
    border_right_color: Color
    border_bottom_color: Color
    border_left_color: Color

    # --- Visual: typography ---
    font_size: float
    font_family: str
    font_weight: FontWeight
    bold: bool
    italic: bool
    text_align: TextAlign
    text_decoration: TextDecoration
    text_transform: TextTransform
    line_height: float
    letter_spacing: float
    max_lines: int
    text_shadow_color: Color
    text_shadow_offset: ShadowOffsetValue
    text_shadow_radius: float

    # --- Visual: shadows / effects ---
    shadow_color: Color
    shadow_offset: ShadowOffsetValue
    shadow_opacity: float
    shadow_radius: float
    elevation: float
    opacity: float
    transform: TransformSpec

    # --- Interaction & stacking ---
    z_index: int
    pointer_events: PointerEvents


StyleProp = Union[Style, Dict[str, Any], List[Optional[Union[Style, Dict[str, Any]]]], None]
"""Public type for the ``style`` parameter on every component factory.

Accepts a [`Style`][pythonnative.Style] TypedDict (recommended), a
plain ``Dict[str, Any]`` (forward-compat / unrestricted), a list of
either with ``None`` entries skipped, or ``None``."""


# ======================================================================
# Runtime helpers
# ======================================================================


def style(**properties: Any) -> Style:
    """Construct a [`Style`][pythonnative.Style] from keyword arguments.

    Equivalent to ``Style(...)`` but works with any Python version
    (``TypedDict.__init__`` is fragile prior to 3.11) and reads more
    naturally inside expressions:

    ```python
    pn.View(child, style=pn.style(padding=16, background_color="#fff"))
    ```

    Unknown keys are accepted at runtime to keep the door open for
    third-party styling extensions; static type checkers will still
    reject them when this helper's return type is annotated as
    ``Style``.

    Args:
        **properties: Style key/value pairs.

    Returns:
        A fresh ``Style`` dict containing the supplied entries.
    """
    return properties  # type: ignore[return-value]


def resolve_style(value: StyleProp) -> Dict[str, Any]:
    """Flatten a `style` prop into a single dict.

    Accepts ``None``, a single dict (``Style`` or untyped), or a list of
    dicts (later entries override earlier ones, mirroring React Native's
    array-style pattern). Used by every built-in element factory in
    `pythonnative.components`.

    Args:
        value: The raw value of the component's `style` argument.

    Returns:
        A flat dict suitable for the native handler. Always a fresh
        dict, never the input.
    """
    if value is None:
        return {}
    if isinstance(value, dict):
        return dict(value)
    result: Dict[str, Any] = {}
    for entry in value:
        if entry:
            result.update(entry)
    return result


_KNOWN_STYLE_KEYS = frozenset(Style.__annotations__)
"""Every key declared on the [`Style`][pythonnative.Style] TypedDict."""

_STYLE_VALUE_CHOICES: Dict[str, frozenset] = {
    "display": frozenset(get_args(Display)),
    "position": frozenset(get_args(Position)),
    "direction": frozenset(get_args(LayoutDirection)),
    "text_decoration": frozenset(get_args(TextDecoration)),
    "text_transform": frozenset(get_args(TextTransform)),
    "pointer_events": frozenset(get_args(PointerEvents)),
}
"""Keys whose string values are checked against their ``Literal`` choices
in dev mode. Only keys whose handlers accept exactly the declared
literals are listed, so friendly aliases (``align_items: "leading"``,
``font_weight: "semibold"``) never trigger false positives."""


def _is_number(value: Any) -> bool:
    return isinstance(value, (int, float)) and not isinstance(value, bool)


def _text_shadow_offset_ok(value: Any) -> bool:
    if isinstance(value, dict):
        return all(_is_number(value.get(k, 0)) for k in ("width", "height"))
    if isinstance(value, (tuple, list)) and len(value) == 2:
        return all(_is_number(v) for v in value)
    return False


def _bad_value_message(key: str, value: Any) -> Optional[str]:
    """Return a description of why ``value`` is invalid for ``key``, or ``None``."""
    choices = _STYLE_VALUE_CHOICES.get(key)
    if choices is not None:
        if not isinstance(value, str) or value not in choices:
            allowed = ", ".join(repr(c) for c in sorted(choices))
            return f"Invalid value {value!r} for style key {key!r}; expected one of {allowed}."
        return None
    if key == "text_shadow_offset" and not _text_shadow_offset_ok(value):
        return (
            f"Invalid value {value!r} for style key 'text_shadow_offset'; "
            "expected {'width': x, 'height': y} or an (x, y) pair."
        )
    if key == "text_shadow_radius" and (not _is_number(value) or value < 0):
        return f"Invalid value {value!r} for style key 'text_shadow_radius'; expected a non-negative number."
    return None


def validate_style_keys(style_dict: Dict[str, Any], owner: str = "") -> None:
    """Warn (once per key) about style keys no built-in handler reads.

    Dev-mode only; production skips the scan entirely. Typos get a
    "did you mean" suggestion via fuzzy matching against the declared
    [`Style`][pythonnative.Style] keys. Unknown keys still flow through
    to handlers untouched, so custom components that read extra keys
    keep working (at the cost of one dev warning).

    A small set of enum-shaped keys (``display``, ``position``,
    ``direction``, ``text_decoration``, ``text_transform``,
    ``pointer_events``) and the ``text_shadow_*`` keys also have their
    values checked; a bad value warns once per key/value pair and is
    otherwise passed through (the engine and handlers ignore values
    they don't recognize).

    Args:
        style_dict: The flattened style dict about to be merged into an
            element's props.
        owner: Element type name for the warning message (e.g.
            ``"Text"``).
    """
    if not diagnostics.is_dev() or not style_dict:
        return
    for key, value in style_dict.items():
        if key in _KNOWN_STYLE_KEYS:
            if value is None:
                continue
            problem = _bad_value_message(key, value)
            if problem is not None:
                diagnostics.warn_once(
                    problem + (f" (on {owner})" if owner else ""),
                    key=f"style-value:{owner}:{key}:{value!r}",
                )
            continue
        matches = difflib.get_close_matches(str(key), _KNOWN_STYLE_KEYS, n=1)
        hint = f" Did you mean {matches[0]!r}?" if matches else ""
        diagnostics.warn_once(
            f"Unknown style key {key!r}"
            + (f" on {owner}" if owner else "")
            + f".{hint} Built-in components ignore keys they don't recognize.",
            key=f"style:{owner}:{key}",
        )


# ======================================================================
# StyleSheet
# ======================================================================


class StyleSheet:
    """Utility for creating, composing, and flattening style dictionaries.

    All methods are stateless and return fresh dicts, so the values can
    be reused safely across components.
    """

    @staticmethod
    def create(**named_styles: Style) -> Dict[str, Style]:
        """Create a set of named styles from keyword arguments.

        Args:
            **named_styles: Each keyword argument is a style name
                mapping to a [`Style`][pythonnative.Style] dict.

        Returns:
            A dict mapping each name to a copy of the supplied style,
            so the caller can mutate the result without affecting the
            originals.

        Example:
            ```python
            from pythonnative import StyleSheet, style

            styles = StyleSheet.create(
                heading=style(font_size=28, bold=True),
                body=style(font_size=16),
            )
            ```
        """
        return {name: dict(props) for name, props in named_styles.items()}  # type: ignore[misc]

    @staticmethod
    def compose(*styles: StyleProp) -> Style:
        """Merge multiple style dicts.

        Args:
            *styles: Style dicts to merge. Later dicts override keys
                from earlier ones. Falsy entries (``None``) are skipped.
                List entries are flattened in turn.

        Returns:
            A new ``Style`` dict containing the merged result.
        """
        merged: Dict[str, Any] = {}
        for entry in styles:
            if entry is None:
                continue
            if isinstance(entry, dict):
                merged.update(entry)
                continue
            for nested in entry:
                if nested:
                    merged.update(nested)
        return merged  # type: ignore[return-value]

    @staticmethod
    def flatten(styles: StyleProp) -> Style:
        """Flatten a style value or list of styles into a single dict.

        Equivalent to
        [`resolve_style`][pythonnative.style.resolve_style] but exposed
        on `StyleSheet` for parity with React Native's API and typed
        as returning a ``Style``.

        Args:
            styles: A single dict, a list of dicts, or `None`.

        Returns:
            A flat ``Style`` dict combining the inputs.
        """
        return resolve_style(styles)  # type: ignore[return-value]

    @staticmethod
    def absolute_fill() -> Style:
        """Return a style that absolutely fills the parent.

        Convenience preset matching React Native's
        ``StyleSheet.absoluteFill``: ``position: "absolute"`` with
        every inset pinned to ``0``.
        """
        return {"position": "absolute", "top": 0, "right": 0, "bottom": 0, "left": 0}


# ======================================================================
# Theming
# ======================================================================


@dataclass(frozen=True)
class Theme:
    """Design tokens read through [`use_theme`][pythonnative.use_theme].

    A ``Theme`` is an immutable, fully typed record, so
    ``theme.text_color`` autocompletes and a typo is a static error
    rather than a runtime ``KeyError``. Derive a custom theme from a
    built-in one with [`replace`][pythonnative.style.Theme.replace]:

    ```python
    brand = pn.DEFAULT_LIGHT_THEME.replace(primary_color="#FF2D55")
    ```

    Attributes:
        primary_color: Main accent color.
        secondary_color: Secondary accent color.
        background_color: Screen background.
        surface_color: Raised surfaces such as cards and sheets.
        text_color: Primary text.
        text_secondary_color: De-emphasized text.
        error_color: Destructive and error states.
        success_color: Success states.
        warning_color: Warning states.
        font_size: Body text size, in points.
        font_size_small: Caption size, in points.
        font_size_large: Subtitle size, in points.
        font_size_title: Title size, in points.
        spacing: Base spacing unit, in points.
        spacing_large: Large spacing unit, in points.
        border_radius: Default corner radius, in points.
    """

    primary_color: Color
    secondary_color: Color
    background_color: Color
    surface_color: Color
    text_color: Color
    text_secondary_color: Color
    error_color: Color
    success_color: Color
    warning_color: Color
    font_size: float = 16
    font_size_small: float = 13
    font_size_large: float = 20
    font_size_title: float = 28
    spacing: float = 8
    spacing_large: float = 16
    border_radius: float = 8

    def replace(self, **changes: Any) -> "Theme":
        """Return a copy of this theme with the given fields replaced.

        Raises:
            TypeError: If ``changes`` names a field that doesn't exist.
        """
        return dataclasses.replace(self, **changes)


DEFAULT_LIGHT_THEME = Theme(
    primary_color="#007AFF",
    secondary_color="#5856D6",
    background_color="#FFFFFF",
    surface_color="#F2F2F7",
    text_color="#000000",
    text_secondary_color="#8E8E93",
    error_color="#FF3B30",
    success_color="#34C759",
    warning_color="#FF9500",
)
"""Built-in light theme selected by [`use_theme`][pythonnative.use_theme]."""

DEFAULT_DARK_THEME = Theme(
    primary_color="#0A84FF",
    secondary_color="#5E5CE6",
    background_color="#000000",
    surface_color="#1C1C1E",
    text_color="#FFFFFF",
    text_secondary_color="#8E8E93",
    error_color="#FF453A",
    success_color="#30D158",
    warning_color="#FF9F0A",
)
"""Built-in dark theme selected by [`use_theme`][pythonnative.use_theme]."""

_FOLLOW_SYSTEM_THEME = object()
"""Sentinel default for `ThemeContext`: resolve from the color scheme."""

ThemeContext: Context = create_context(_FOLLOW_SYSTEM_THEME)
"""Theme context that follows the system color scheme by default.

Without a provider, [`use_theme`][pythonnative.use_theme] resolves to
[`DEFAULT_LIGHT_THEME`][pythonnative.style.DEFAULT_LIGHT_THEME] or
[`DEFAULT_DARK_THEME`][pythonnative.style.DEFAULT_DARK_THEME] based on
the current appearance. Wrap a subtree in
[`ThemeContext.Provider(my_theme, ...)`][pythonnative.hooks.Context.Provider] to
pin an explicit theme for that subtree, then read it inside
descendants via [`use_theme`][pythonnative.use_theme] (or
[`use_context(ThemeContext)`][pythonnative.use_context]).
"""


def default_theme(scheme: str) -> Theme:
    """Return the built-in [`Theme`][pythonnative.Theme] for ``scheme`` (``"light"`` / ``"dark"``)."""
    return DEFAULT_DARK_THEME if scheme == "dark" else DEFAULT_LIGHT_THEME


def use_theme() -> Theme:
    """Return the active [`Theme`][pythonnative.Theme], following the system appearance by default.

    If an ancestor mounted a ``ThemeContext.Provider(...)``, that
    theme is returned as-is. Otherwise the built-in light or dark
    theme is selected from the effective color scheme (via
    [`use_color_scheme`][pythonnative.use_color_scheme], so the
    component re-renders when the system appearance flips).

    Returns:
        The active theme.

    Raises:
        RuntimeError: If called outside a `@component` function.

    Example:
        ```python
        import pythonnative as pn

        @pn.component
        def Card():
            theme = pn.use_theme()
            return pn.View(
                pn.Text("Hello", style=pn.style(color=theme.text_color)),
                style=pn.style(background_color=theme.surface_color),
            )
        ```
    """
    scheme = use_color_scheme()
    theme = use_context(ThemeContext)
    if theme is _FOLLOW_SYSTEM_THEME:
        return default_theme(scheme)
    if not isinstance(theme, Theme):
        raise TypeError(f"ThemeContext.Provider expects a pn.Theme, got {type(theme).__name__}: {theme!r}")
    return theme


__all__ = [
    "AlignItems",
    "AlignSelf",
    "AutoCapitalize",
    "Color",
    "DEFAULT_DARK_THEME",
    "DEFAULT_LIGHT_THEME",
    "Dimension",
    "Display",
    "EdgeInsets",
    "EdgeValue",
    "FlexDirection",
    "FontWeight",
    "JustifyContent",
    "KeyboardType",
    "MarginValue",
    "Overflow",
    "PointerEvents",
    "Position",
    "ReturnKeyType",
    "ScaleType",
    "ShadowOffset",
    "ShadowOffsetValue",
    "Style",
    "StyleProp",
    "StyleSheet",
    "TextAlign",
    "TextDecoration",
    "TextTransform",
    "Theme",
    "ThemeContext",
    "TransformEntry",
    "TransformRotate",
    "TransformScale",
    "TransformScaleX",
    "TransformScaleY",
    "TransformSpec",
    "TransformTranslate",
    "default_theme",
    "resolve_style",
    "style",
    "use_theme",
    "validate_style_keys",
]

Web Proxy Viewer  |  New URL  |  Original Page