Source code for wijjit.styling.style

"""CSS-like styling system for terminal UI elements.

This module provides the Style class for defining element appearance using
a CSS-like approach with colors, text attributes, and cascade/merge support.
"""

from dataclasses import dataclass
from typing import Any


[docs] @dataclass class Style: """CSS-like style specification for terminal UI elements. This class represents a complete style specification including colors and text attributes. Styles can be merged using CSS-like cascade rules. Parameters ---------- fg_color : tuple of (int, int, int) or None, optional Foreground RGB color (0-255 each) or None for default terminal color bg_color : tuple of (int, int, int) or None, optional Background RGB color (0-255 each) or None for default terminal color bold : bool or None, optional Bold text attribute. None means unspecified (default: None) italic : bool or None, optional Italic text attribute. None means unspecified (default: None) underline : bool or None, optional Underline text attribute. None means unspecified (default: None) dim : bool or None, optional Dim/faint text attribute. None means unspecified (default: None) reverse : bool or None, optional Reverse video (swap fg/bg colors) attribute. None means unspecified (default: None) Attributes ---------- fg_color : tuple of (int, int, int) or None Foreground color in RGB bg_color : tuple of (int, int, int) or None Background color in RGB bold : bool or None Bold attribute (None = unspecified) italic : bool or None Italic attribute (None = unspecified) underline : bool or None Underline attribute (None = unspecified) dim : bool or None Dim attribute (None = unspecified) reverse : bool or None Reverse video attribute (None = unspecified) Examples -------- Create a basic style: >>> style = Style(fg_color=(255, 0, 0), bold=True) >>> style.fg_color (255, 0, 0) >>> style.bold True Merge two styles (CSS cascade): >>> base = Style(fg_color=(255, 0, 0), bold=True) >>> override = Style(fg_color=(0, 255, 0)) # New color, keeps bold >>> merged = base.merge(override) >>> merged.fg_color (0, 255, 0) >>> merged.bold True Disable attributes via merge: >>> base = Style(bold=True, italic=True) >>> override = Style(bold=False) # Explicitly disable bold >>> merged = base.merge(override) >>> merged.bold False >>> merged.italic True Convert to Cell attributes: >>> style = Style(fg_color=(255, 0, 0), bold=True) >>> attrs = style.to_cell_attrs() >>> attrs['fg_color'] (255, 0, 0) >>> attrs['bold'] True """ fg_color: tuple[int, int, int] | None = None bg_color: tuple[int, int, int] | None = None bold: bool | None = None italic: bool | None = None underline: bool | None = None dim: bool | None = None reverse: bool | None = None
[docs] def merge(self, other: "Style") -> "Style": """Merge another style on top of this one (CSS cascade). Parameters ---------- other : Style Style to overlay on top of this style Returns ------- Style New style with other's non-None values overriding self's values Notes ----- This implements CSS-like cascade behavior where properties from 'other' override properties from 'self'. For color properties, None means "use default" while a color tuple overrides. For boolean attributes, True overrides False. The merge is non-destructive - both input styles remain unchanged. Examples -------- Merge colors: >>> base = Style(fg_color=(255, 0, 0)) >>> override = Style(fg_color=(0, 255, 0)) >>> merged = base.merge(override) >>> merged.fg_color (0, 255, 0) Merge attributes: >>> base = Style(bold=True) >>> override = Style(italic=True) >>> merged = base.merge(override) >>> merged.bold True >>> merged.italic True Override with None preserves base: >>> base = Style(fg_color=(255, 0, 0)) >>> override = Style(bg_color=(0, 0, 255)) >>> merged = base.merge(override) >>> merged.fg_color (255, 0, 0) >>> merged.bg_color (0, 0, 255) """ return Style( fg_color=other.fg_color if other.fg_color is not None else self.fg_color, bg_color=other.bg_color if other.bg_color is not None else self.bg_color, bold=other.bold if other.bold is not None else self.bold, italic=other.italic if other.italic is not None else self.italic, underline=( other.underline if other.underline is not None else self.underline ), dim=other.dim if other.dim is not None else self.dim, reverse=other.reverse if other.reverse is not None else self.reverse, )
[docs] def to_cell_attrs(self) -> dict[str, Any]: """Convert style to Cell constructor attributes. Returns ------- dict Dictionary of attributes suitable for ``Cell(**attrs)`` Notes ----- This is the PRIMARY method for converting styles to cell attributes in the cell-based rendering system. Returns a dictionary that can be unpacked directly into Cell constructor. Examples -------- Convert to cell attributes: >>> style = Style(fg_color=(255, 0, 0), bold=True) >>> attrs = style.to_cell_attrs() >>> attrs {'fg_color': (255, 0, 0), 'bg_color': None, 'bold': True, 'italic': False, 'underline': False, 'reverse': False, 'dim': False} Use with Cell: >>> from wijjit.terminal.cell import Cell >>> style = Style(fg_color=(0, 255, 0)) >>> cell = Cell('A', **style.to_cell_attrs()) >>> cell.fg_color (0, 255, 0) """ return { "fg_color": self.fg_color, "bg_color": self.bg_color, "bold": self.bold if self.bold is not None else False, "italic": self.italic if self.italic is not None else False, "underline": self.underline if self.underline is not None else False, "reverse": self.reverse if self.reverse is not None else False, "dim": self.dim if self.dim is not None else False, }
[docs] def __bool__(self) -> bool: """Check if style has any non-default properties. Returns ------- bool True if any style property is set, False if default/empty Notes ----- Useful for conditionally applying styles or checking if a style has any effect. Examples -------- Empty style is falsy: >>> style = Style() >>> bool(style) False Styled is truthy: >>> style = Style(bold=True) >>> bool(style) True """ return ( self.fg_color is not None or self.bg_color is not None or (self.bold is not None and self.bold) or (self.italic is not None and self.italic) or (self.underline is not None and self.underline) or (self.dim is not None and self.dim) or (self.reverse is not None and self.reverse) )
# Predefined common styles for convenience DEFAULT_STYLE = Style() BOLD_STYLE = Style(bold=True) ITALIC_STYLE = Style(italic=True) UNDERLINE_STYLE = Style(underline=True) DIM_STYLE = Style(dim=True) def parse_color(color_str: str) -> tuple[int, int, int] | None: """Parse color string to RGB tuple. Parameters ---------- color_str : str Color specification in various formats: - Hex: "#FF0000" or "#F00" - RGB: "rgb(255, 0, 0)" - Named: "red", "blue", etc. (basic colors only) Returns ------- tuple of (int, int, int) or None RGB color tuple or None if parsing failed Notes ----- Supports common color formats for ease of use. Named colors are limited to basic terminal colors for consistency. Examples -------- Parse hex color: >>> parse_color("#FF0000") (255, 0, 0) >>> parse_color("#F00") (255, 0, 0) Parse RGB: >>> parse_color("rgb(255, 0, 0)") (255, 0, 0) Parse named color: >>> parse_color("red") (255, 0, 0) Invalid returns None: >>> parse_color("invalid") """ color_str = color_str.strip().lower() # Hex format: #RGB or #RRGGBB if color_str.startswith("#"): hex_str = color_str[1:] try: if len(hex_str) == 3: # Short form: #RGB -> #RRGGBB r = int(hex_str[0] * 2, 16) g = int(hex_str[1] * 2, 16) b = int(hex_str[2] * 2, 16) return (r, g, b) elif len(hex_str) == 6: # Long form: #RRGGBB r = int(hex_str[0:2], 16) g = int(hex_str[2:4], 16) b = int(hex_str[4:6], 16) return (r, g, b) except ValueError: # Invalid hex digits return None # RGB format: rgb(R, G, B) if color_str.startswith("rgb(") and color_str.endswith(")"): rgb_str = color_str[4:-1] parts = [p.strip() for p in rgb_str.split(",")] if len(parts) == 3: try: r, g, b = int(parts[0]), int(parts[1]), int(parts[2]) # Clamp values to valid 0-255 range r = max(0, min(255, r)) g = max(0, min(255, g)) b = max(0, min(255, b)) return (r, g, b) except ValueError: pass # Named colors (CSS basic colors) named_colors = { # Basic terminal colors "black": (0, 0, 0), "red": (255, 0, 0), "green": (0, 255, 0), "yellow": (255, 255, 0), "blue": (0, 0, 255), "magenta": (255, 0, 255), "cyan": (0, 255, 255), "white": (255, 255, 255), "gray": (128, 128, 128), "grey": (128, 128, 128), # Extended CSS colors "orange": (255, 165, 0), "purple": (128, 0, 128), "pink": (255, 192, 203), "brown": (165, 42, 42), "lime": (0, 255, 0), "navy": (0, 0, 128), "teal": (0, 128, 128), "silver": (192, 192, 192), } return named_colors.get(color_str)