forked from username/gitea-ci
add style module dependency
This commit is contained in:
347
style.py
Normal file
347
style.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user