348 lines
11 KiB
Python
348 lines
11 KiB
Python
#!/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
|