From 14ab3574f94370a5be95ee15276c2c46d7a5c46e Mon Sep 17 00:00:00 2001 From: Username Date: Sun, 18 Jan 2026 17:50:00 +0100 Subject: [PATCH] add style module dependency --- style.py | 347 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 style.py diff --git a/style.py b/style.py new file mode 100644 index 0000000..5181e11 --- /dev/null +++ b/style.py @@ -0,0 +1,347 @@ +#!/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