#!/usr/bin/env python3 """style.py - Shared styling utilities for Python CLI tools. Provides consistent color handling, Unicode symbols, and output formatting. Respects NO_COLOR environment variable (https://no-color.org/). Usage: from style import Color, Symbol, Diff, Cursor, Spinner, progress_bar print(f"{Color.BLD}Header{Color.RST}") print(f"{Symbol.OK} Success") print(f"{Diff.ADD}+ added{Color.RST}") print(f"{Color.fg256(208)}orange{Color.RST}") print(f"{Cursor.HIDE}Working...{Cursor.SHOW}") print(f"Loading: {progress_bar(0.5)}") """ import os import sys def _has_color() -> bool: """Check if colors should be enabled.""" if os.environ.get("NO_COLOR"): return False return sys.stdout.isatty() class Color: """ANSI color codes - respects NO_COLOR and TTY detection. Curated palette optimized for dark terminals: non-invasive, professional, readable. Prefer the refined 256-color variants over standard ANSI. Usage: print(f"{Color.BLD}Bold text{Color.RST}") print(f"{Color.OK}Success{Color.RST}") # Refined sage green print(f"{Color.ERR}Error{Color.RST}") # Refined coral red """ _enabled = _has_color() # Reset and styles RST = "\033[0m" if _enabled else "" BLD = "\033[1m" if _enabled else "" DIM = "\033[2m" if _enabled else "" ITL = "\033[3m" if _enabled else "" UND = "\033[4m" if _enabled else "" STK = "\033[9m" if _enabled else "" # ───────────────────────────────────────────────────────────────────────── # Refined palette (256-color) - Curated for dark terminals # Non-invasive, professional, highly readable # ───────────────────────────────────────────────────────────────────────── OK = "\033[38;5;108m" if _enabled else "" # Sage green - success, calm ERR = "\033[38;5;167m" if _enabled else "" # Soft coral - errors, clear WARN = "\033[38;5;179m" if _enabled else "" # Muted gold - warnings INFO = "\033[38;5;67m" if _enabled else "" # Steel blue - info, links NOTE = "\033[38;5;139m" if _enabled else "" # Dusty lavender - notes MUTE = "\033[38;5;102m" if _enabled else "" # Gray - secondary text # ───────────────────────────────────────────────────────────────────────── # Standard ANSI colors (legacy compatibility) # ───────────────────────────────────────────────────────────────────────── RED = "\033[31m" if _enabled else "" GRN = "\033[32m" if _enabled else "" YLW = "\033[33m" if _enabled else "" BLU = "\033[34m" if _enabled else "" MAG = "\033[35m" if _enabled else "" CYN = "\033[36m" if _enabled else "" WHT = "\033[37m" if _enabled else "" # Bright variants (use sparingly - can be harsh) BRED = "\033[1;31m" if _enabled else "" BGRN = "\033[1;32m" if _enabled else "" BYLW = "\033[1;33m" if _enabled else "" # Long name aliases for compatibility BOLD = BLD GREEN = GRN YELLOW = YLW BLUE = BLU MAGENTA = MAG CYAN = CYN WHITE = WHT STRIKE = STK @classmethod def enabled(cls) -> bool: """Check if colors are currently enabled.""" return cls._enabled @classmethod def fg256(cls, n: int) -> str: """256-color foreground. Usage: Color.fg256(208) for orange.""" if not cls._enabled: return "" return f"\033[38;5;{n}m" @classmethod def bg256(cls, n: int) -> str: """256-color background. Usage: Color.bg256(52) for dark red.""" if not cls._enabled: return "" return f"\033[48;5;{n}m" @classmethod def fg_rgb(cls, r: int, g: int, b: int) -> str: """True color (24-bit) foreground.""" if not cls._enabled: return "" return f"\033[38;2;{r};{g};{b}m" @classmethod def bg_rgb(cls, r: int, g: int, b: int) -> str: """True color (24-bit) background.""" if not cls._enabled: return "" return f"\033[48;2;{r};{g};{b}m" class Diff: """Muted diff colors for professional output. Usage: print(f"{Diff.DEL}- removed line{Color.RST}") print(f"{Diff.ADD}+ added line{Color.RST}") """ _enabled = _has_color() # Muted backgrounds (256-color palette) DEL = "\033[48;5;52m" if _enabled else "" # Dark red ADD = "\033[48;5;22m" if _enabled else "" # Dark green CHG = "\033[48;5;58m" if _enabled else "" # Dark yellow/olive class Cursor: """ANSI cursor control sequences. Usage: print(f"{Cursor.HIDE}Processing...{Cursor.SHOW}") print(f"Line 1{Cursor.UP}Replaced") print(f"{Cursor.COL1}Start of line") """ _enabled = _has_color() # Cursor visibility HIDE = "\033[?25l" if _enabled else "" SHOW = "\033[?25h" if _enabled else "" # Line control CLEAR_LINE = "\033[2K" if _enabled else "" CLEAR_TO_END = "\033[0K" if _enabled else "" COL1 = "\033[1G" if _enabled else "" # Move to column 1 # Single movement UP = "\033[1A" if _enabled else "" DOWN = "\033[1B" if _enabled else "" RIGHT = "\033[1C" if _enabled else "" LEFT = "\033[1D" if _enabled else "" @classmethod def up(cls, n: int = 1) -> str: """Move cursor up n lines.""" if not cls._enabled or n < 1: return "" return f"\033[{n}A" @classmethod def down(cls, n: int = 1) -> str: """Move cursor down n lines.""" if not cls._enabled or n < 1: return "" return f"\033[{n}B" @classmethod def right(cls, n: int = 1) -> str: """Move cursor right n columns.""" if not cls._enabled or n < 1: return "" return f"\033[{n}C" @classmethod def left(cls, n: int = 1) -> str: """Move cursor left n columns.""" if not cls._enabled or n < 1: return "" return f"\033[{n}D" @classmethod def col(cls, n: int = 1) -> str: """Move cursor to column n (1-indexed).""" if not cls._enabled or n < 1: return "" return f"\033[{n}G" @classmethod def pos(cls, row: int, col: int) -> str: """Move cursor to absolute position (1-indexed).""" if not cls._enabled or row < 1 or col < 1: return "" return f"\033[{row};{col}H" @classmethod def save(cls) -> str: """Save cursor position.""" return "\033[s" if cls._enabled else "" @classmethod def restore(cls) -> str: """Restore cursor position.""" return "\033[u" if cls._enabled else "" class Symbol: """Unicode symbols for CLI output. Usage: print(f"{Symbol.OK} Task complete") print(f"{Symbol.FAIL} Task failed") """ # Status indicators OK = "✓" FAIL = "✗" WARN = "⚠" # State indicators ACTIVE = "●" INACTIVE = "○" PARTIAL = "◐" # Flow indicators ARROW = "→" LARROW = "←" # Box drawing (light) H_LINE = "─" V_LINE = "│" TL = "┌" TR = "┐" BL = "└" BR = "┘" L_TEE = "├" R_TEE = "┤" # Separators SEP = "─" * 40 SEP_DOUBLE = "═" * 40 @classmethod def status(cls, success: bool) -> str: """Get colored status symbol using refined palette.""" if success: return f"{Color.OK}{cls.OK}{Color.RST}" return f"{Color.ERR}{cls.FAIL}{Color.RST}" @classmethod def status_str(cls, status: str) -> str: """Get colored symbol for status string using refined palette.""" styles = { "success": (Color.OK, cls.OK), "ok": (Color.OK, cls.OK), "failure": (Color.ERR, cls.FAIL), "failed": (Color.ERR, cls.FAIL), "error": (Color.ERR, cls.FAIL), "warning": (Color.WARN, cls.WARN), "warn": (Color.WARN, cls.WARN), "running": (Color.INFO, cls.ACTIVE), "active": (Color.INFO, cls.ACTIVE), "pending": (Color.MUTE, cls.INACTIVE), "waiting": (Color.MUTE, cls.INACTIVE), "cancelled": (Color.WARN, "⊘"), "skipped": (Color.MUTE, "⊘"), "queued": (Color.MUTE, "◌"), } color, sym = styles.get(status.lower(), (Color.MUTE, cls.INACTIVE)) return f"{color}{sym}{Color.RST}" def sep_line(width: int = 40, double: bool = False) -> str: """Generate separator line.""" char = "═" if double else "─" return char * width class Spinner: """Animated spinner for progress indication. Usage: spinner = Spinner() for item in items: print(f"{Cursor.COL1}{spinner.next()} Processing...", end="", flush=True) print(f"{Cursor.CLEAR_LINE}{Cursor.COL1}Done") """ # Pre-defined frame sets BRAILLE = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") DOTS = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷") CIRCLE = ("◐", "◓", "◑", "◒") LINE = ("─", "\\", "│", "/") ARROW = ("←", "↖", "↑", "↗", "→", "↘", "↓", "↙") def __init__(self, frames: tuple[str, ...] | None = None) -> None: """Initialize spinner with optional custom frames.""" self.frames = frames or self.BRAILLE self.index = 0 def next(self) -> str: """Get next frame and advance.""" frame = self.frames[self.index] self.index = (self.index + 1) % len(self.frames) return frame def reset(self) -> None: """Reset to first frame.""" self.index = 0 def progress_bar( percent: float, width: int = 20, fill: str = "█", empty: str = "░", show_percent: bool = True, ) -> str: """Generate a progress bar string. Usage: print(f"Progress: {progress_bar(0.75)}") # Output: Progress: ███████████████░░░░░ 75% Args: percent: Progress value 0.0 to 1.0 width: Bar width in characters fill: Character for filled portion empty: Character for empty portion show_percent: Append percentage text """ percent = max(0.0, min(1.0, percent)) # Clamp to 0-1 filled = int(width * percent) bar = fill * filled + empty * (width - filled) if show_percent: return f"{bar} {int(percent * 100):3d}%" return bar