curint 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- curint/__init__.py +101 -0
- curint/_compat.py +46 -0
- curint/animations.py +109 -0
- curint/colors.py +152 -0
- curint/console.py +143 -0
- curint/logging.py +47 -0
- curint/progress.py +107 -0
- curint/prompt.py +38 -0
- curint/text.py +150 -0
- curint/theme.py +119 -0
- curint-0.1.2.dist-info/LICENSE +21 -0
- curint-0.1.2.dist-info/METADATA +171 -0
- curint-0.1.2.dist-info/RECORD +15 -0
- curint-0.1.2.dist-info/WHEEL +5 -0
- curint-0.1.2.dist-info/top_level.txt +1 -0
curint/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .animations import (
|
|
4
|
+
bounce,
|
|
5
|
+
bounce_frames,
|
|
6
|
+
glitch,
|
|
7
|
+
glitch_frames,
|
|
8
|
+
print_animated,
|
|
9
|
+
print_bounce,
|
|
10
|
+
print_wave,
|
|
11
|
+
shimmer,
|
|
12
|
+
shimmer_frames,
|
|
13
|
+
typewriter,
|
|
14
|
+
wave,
|
|
15
|
+
wave_frames,
|
|
16
|
+
)
|
|
17
|
+
from .colors import PALETTES, RGB, ansi_bg, ansi_fg, blend_many, parse_color, strip_ansi
|
|
18
|
+
from .console import SpectraConsole, debug, error, info, success, warning
|
|
19
|
+
from .logging import SpectraLogHandler, configure_logging
|
|
20
|
+
from .progress import Spinner, SpinnerRich, progress_bar, progress_bar_rich, rich_status, track_iter
|
|
21
|
+
from .prompt import ask, choose, confirm
|
|
22
|
+
from .text import (
|
|
23
|
+
box,
|
|
24
|
+
center,
|
|
25
|
+
color_text,
|
|
26
|
+
cprint,
|
|
27
|
+
gradient_text,
|
|
28
|
+
highlight,
|
|
29
|
+
palette_text,
|
|
30
|
+
print_color,
|
|
31
|
+
print_gradient,
|
|
32
|
+
print_gradient_rich,
|
|
33
|
+
print_rainbow,
|
|
34
|
+
rainbow_text,
|
|
35
|
+
sky_gradient,
|
|
36
|
+
style_text,
|
|
37
|
+
termcolor_text,
|
|
38
|
+
)
|
|
39
|
+
from .theme import THEMES, Theme, get_theme, register_theme, theme_names, use_theme
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"PALETTES",
|
|
43
|
+
"RGB",
|
|
44
|
+
"THEMES",
|
|
45
|
+
"SpectraConsole",
|
|
46
|
+
"SpectraLogHandler",
|
|
47
|
+
"Spinner",
|
|
48
|
+
"SpinnerRich",
|
|
49
|
+
"Theme",
|
|
50
|
+
"ansi_bg",
|
|
51
|
+
"ansi_fg",
|
|
52
|
+
"ask",
|
|
53
|
+
"blend_many",
|
|
54
|
+
"bounce",
|
|
55
|
+
"bounce_frames",
|
|
56
|
+
"box",
|
|
57
|
+
"center",
|
|
58
|
+
"choose",
|
|
59
|
+
"color_text",
|
|
60
|
+
"configure_logging",
|
|
61
|
+
"confirm",
|
|
62
|
+
"cprint",
|
|
63
|
+
"debug",
|
|
64
|
+
"error",
|
|
65
|
+
"get_theme",
|
|
66
|
+
"glitch",
|
|
67
|
+
"glitch_frames",
|
|
68
|
+
"gradient_text",
|
|
69
|
+
"highlight",
|
|
70
|
+
"info",
|
|
71
|
+
"palette_text",
|
|
72
|
+
"parse_color",
|
|
73
|
+
"print_animated",
|
|
74
|
+
"print_bounce",
|
|
75
|
+
"print_color",
|
|
76
|
+
"print_gradient",
|
|
77
|
+
"print_gradient_rich",
|
|
78
|
+
"print_rainbow",
|
|
79
|
+
"print_wave",
|
|
80
|
+
"progress_bar",
|
|
81
|
+
"progress_bar_rich",
|
|
82
|
+
"rainbow_text",
|
|
83
|
+
"register_theme",
|
|
84
|
+
"rich_status",
|
|
85
|
+
"shimmer",
|
|
86
|
+
"shimmer_frames",
|
|
87
|
+
"sky_gradient",
|
|
88
|
+
"strip_ansi",
|
|
89
|
+
"style_text",
|
|
90
|
+
"success",
|
|
91
|
+
"termcolor_text",
|
|
92
|
+
"theme_names",
|
|
93
|
+
"track_iter",
|
|
94
|
+
"typewriter",
|
|
95
|
+
"use_theme",
|
|
96
|
+
"warning",
|
|
97
|
+
"wave",
|
|
98
|
+
"wave_frames",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
__version__ = "0.1.2"
|
curint/_compat.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Compatibility shims for optional imports during local development.
|
|
2
|
+
|
|
3
|
+
The package declares its core dependencies in setup.py. The fallbacks below
|
|
4
|
+
let tests and docs render in constrained environments before installation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try: # pragma: no cover - exercised when dependency is installed
|
|
12
|
+
from colorama import Back, Fore, Style, just_fix_windows_console
|
|
13
|
+
except Exception: # pragma: no cover - fallback for source tree tests
|
|
14
|
+
class _Codes:
|
|
15
|
+
BLACK = RED = GREEN = YELLOW = BLUE = MAGENTA = CYAN = WHITE = RESET = RESET_ALL = ""
|
|
16
|
+
BRIGHT = DIM = NORMAL = ""
|
|
17
|
+
|
|
18
|
+
Fore = Back = Style = _Codes() # type: ignore[assignment]
|
|
19
|
+
|
|
20
|
+
def just_fix_windows_console() -> None:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
try: # pragma: no cover
|
|
24
|
+
from termcolor import colored
|
|
25
|
+
except Exception: # pragma: no cover
|
|
26
|
+
def colored(text: str, color: str | None = None, on_color: str | None = None, attrs: list[str] | None = None) -> str:
|
|
27
|
+
return text
|
|
28
|
+
|
|
29
|
+
try: # pragma: no cover
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.markdown import Markdown
|
|
32
|
+
from rich.panel import Panel
|
|
33
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, track
|
|
34
|
+
from rich.table import Table
|
|
35
|
+
from rich.text import Text
|
|
36
|
+
from rich.tree import Tree
|
|
37
|
+
except Exception as exc: # pragma: no cover
|
|
38
|
+
raise RuntimeError("curint requires rich. Install with `pip install curint`.") from exc
|
|
39
|
+
|
|
40
|
+
try: # pragma: no cover
|
|
41
|
+
import skytext as _skytext
|
|
42
|
+
except Exception: # pragma: no cover
|
|
43
|
+
_skytext = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
SKYTEXT: Any = _skytext
|
curint/animations.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Animated terminal text helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterator, Sequence
|
|
9
|
+
|
|
10
|
+
from ._compat import Console
|
|
11
|
+
from .colors import strip_ansi
|
|
12
|
+
from .text import gradient_text, rainbow_text, style_text
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def typewriter(text: str, *, delay: float = 0.03, color: str | Sequence[int] | None = None, end: str = "\n") -> None:
|
|
18
|
+
"""Print text one character at a time."""
|
|
19
|
+
|
|
20
|
+
styled = style_text(text, fg=color) if color else text
|
|
21
|
+
for char in styled:
|
|
22
|
+
sys.stdout.write(char)
|
|
23
|
+
sys.stdout.flush()
|
|
24
|
+
time.sleep(delay)
|
|
25
|
+
sys.stdout.write(end)
|
|
26
|
+
sys.stdout.flush()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def wave_frames(text: str, *, height: int = 2, cycles: int = 1) -> Iterator[str]:
|
|
30
|
+
"""Generate simple vertical wave frames for testing or playback."""
|
|
31
|
+
|
|
32
|
+
height = max(1, height)
|
|
33
|
+
clean = strip_ansi(text)
|
|
34
|
+
for offset in range(max(1, cycles * len(clean))):
|
|
35
|
+
lines = [[" " for _ in clean] for _ in range(height + 1)]
|
|
36
|
+
for index, char in enumerate(clean):
|
|
37
|
+
row = abs((index + offset) % (height * 2) - height)
|
|
38
|
+
lines[row][index] = char
|
|
39
|
+
yield "\n".join("".join(line).rstrip() for line in lines)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def wave(text: str, *, color: str | Sequence[int] | None = None, height: int = 2, cycles: int = 2, delay: float = 0.08) -> None:
|
|
43
|
+
for frame in wave_frames(text, height=height, cycles=cycles):
|
|
44
|
+
console.clear()
|
|
45
|
+
console.print(style_text(frame, fg=color) if color else frame, markup=False)
|
|
46
|
+
time.sleep(delay)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bounce_frames(text: str, *, width: int = 40, cycles: int = 2) -> Iterator[str]:
|
|
50
|
+
clean = strip_ansi(text)
|
|
51
|
+
width = max(len(clean), width)
|
|
52
|
+
span = width - len(clean)
|
|
53
|
+
path = list(range(span + 1)) + list(range(max(0, span - 1), 0, -1))
|
|
54
|
+
for _ in range(cycles):
|
|
55
|
+
for pos in path or [0]:
|
|
56
|
+
yield " " * pos + clean
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def bounce(text: str, *, color: str | Sequence[int] | None = None, width: int = 40, cycles: int = 2, delay: float = 0.05) -> None:
|
|
60
|
+
for frame in bounce_frames(text, width=width, cycles=cycles):
|
|
61
|
+
sys.stdout.write("\r" + style_text(frame, fg=color) if color else "\r" + frame)
|
|
62
|
+
sys.stdout.flush()
|
|
63
|
+
time.sleep(delay)
|
|
64
|
+
sys.stdout.write("\n")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def shimmer_frames(text: str, *, cycles: int = 1) -> Iterator[str]:
|
|
68
|
+
clean = strip_ansi(text)
|
|
69
|
+
for phase in range(max(1, len(clean) * cycles)):
|
|
70
|
+
pieces: list[str] = []
|
|
71
|
+
for index, char in enumerate(clean):
|
|
72
|
+
if char.isspace():
|
|
73
|
+
pieces.append(char)
|
|
74
|
+
elif index == phase % len(clean):
|
|
75
|
+
pieces.append(style_text(char, fg="#ffffff", bold=True))
|
|
76
|
+
else:
|
|
77
|
+
pieces.append(style_text(char, fg="#6272a4"))
|
|
78
|
+
yield "".join(pieces)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def shimmer(text: str, *, cycles: int = 2, delay: float = 0.04) -> None:
|
|
82
|
+
for frame in shimmer_frames(text, cycles=cycles):
|
|
83
|
+
sys.stdout.write("\r" + frame)
|
|
84
|
+
sys.stdout.flush()
|
|
85
|
+
time.sleep(delay)
|
|
86
|
+
sys.stdout.write("\n")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def glitch_frames(text: str, *, intensity: float = 0.12, frames: int = 8, charset: str = "!@#$%^&*<>?/\\|~") -> Iterator[str]:
|
|
90
|
+
clean = strip_ansi(text)
|
|
91
|
+
intensity = min(1.0, max(0.0, intensity))
|
|
92
|
+
for _ in range(max(1, frames)):
|
|
93
|
+
chars = [random.choice(charset) if char.strip() and random.random() < intensity else char for char in clean]
|
|
94
|
+
yield rainbow_text("".join(chars), bold=True)
|
|
95
|
+
yield clean
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def glitch(text: str, *, intensity: float = 0.12, frames: int = 8, delay: float = 0.05) -> None:
|
|
99
|
+
for frame in glitch_frames(text, intensity=intensity, frames=frames):
|
|
100
|
+
sys.stdout.write("\r" + frame)
|
|
101
|
+
sys.stdout.flush()
|
|
102
|
+
time.sleep(delay)
|
|
103
|
+
sys.stdout.write("\n")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Convenience aliases.
|
|
107
|
+
print_animated = typewriter
|
|
108
|
+
print_wave = wave
|
|
109
|
+
print_bounce = bounce
|
curint/colors.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Color parsing and ANSI helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Iterable, Iterator, Sequence
|
|
9
|
+
|
|
10
|
+
RGBTuple = tuple[int, int, int]
|
|
11
|
+
|
|
12
|
+
RESET = "\033[0m"
|
|
13
|
+
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
14
|
+
|
|
15
|
+
CSS_COLORS: dict[str, RGBTuple] = {
|
|
16
|
+
"black": (0, 0, 0),
|
|
17
|
+
"red": (255, 85, 85),
|
|
18
|
+
"green": (80, 250, 123),
|
|
19
|
+
"yellow": (241, 250, 140),
|
|
20
|
+
"blue": (98, 114, 164),
|
|
21
|
+
"magenta": (255, 121, 198),
|
|
22
|
+
"cyan": (139, 233, 253),
|
|
23
|
+
"white": (248, 248, 242),
|
|
24
|
+
"orange": (255, 184, 108),
|
|
25
|
+
"purple": (189, 147, 249),
|
|
26
|
+
"pink": (255, 121, 198),
|
|
27
|
+
"gray": (128, 128, 128),
|
|
28
|
+
"grey": (128, 128, 128),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
PALETTES: dict[str, list[RGBTuple]] = {
|
|
32
|
+
"rainbow": [(255, 0, 0), (255, 127, 0), (255, 255, 0), (0, 255, 0), (0, 170, 255), (75, 0, 130), (148, 0, 211)],
|
|
33
|
+
"sunset": [(255, 94, 98), (255, 153, 102), (255, 206, 84), (106, 90, 205)],
|
|
34
|
+
"ocean": [(0, 180, 219), (0, 131, 176), (72, 85, 99)],
|
|
35
|
+
"neon": [(57, 255, 20), (0, 255, 255), (255, 0, 255), (255, 255, 0)],
|
|
36
|
+
"fire": [(255, 0, 0), (255, 120, 0), (255, 255, 0), (255, 255, 255)],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class RGB:
|
|
41
|
+
"""An immutable RGB color."""
|
|
42
|
+
|
|
43
|
+
r: int
|
|
44
|
+
g: int
|
|
45
|
+
b: int
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
for channel in (self.r, self.g, self.b):
|
|
49
|
+
if channel < 0 or channel > 255:
|
|
50
|
+
raise ValueError("RGB channels must be between 0 and 255")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def tuple(self) -> RGBTuple:
|
|
54
|
+
return (self.r, self.g, self.b)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def hex(self) -> str:
|
|
58
|
+
return f"#{self.r:02x}{self.g:02x}{self.b:02x}"
|
|
59
|
+
|
|
60
|
+
def fg(self) -> str:
|
|
61
|
+
return ansi_fg(self.tuple)
|
|
62
|
+
|
|
63
|
+
def bg(self) -> str:
|
|
64
|
+
return ansi_bg(self.tuple)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clamp(value: float, minimum: int = 0, maximum: int = 255) -> int:
|
|
68
|
+
return max(minimum, min(maximum, int(round(value))))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_color(color: str | Sequence[int] | RGB) -> RGBTuple:
|
|
72
|
+
"""Parse a color name, hex value, RGB tuple/list, or :class:`RGB`."""
|
|
73
|
+
|
|
74
|
+
if isinstance(color, RGB):
|
|
75
|
+
return color.tuple
|
|
76
|
+
if isinstance(color, str):
|
|
77
|
+
value = color.strip().lower()
|
|
78
|
+
if value in CSS_COLORS:
|
|
79
|
+
return CSS_COLORS[value]
|
|
80
|
+
if value.startswith("#"):
|
|
81
|
+
value = value[1:]
|
|
82
|
+
if len(value) == 3 and all(ch in "0123456789abcdef" for ch in value):
|
|
83
|
+
return tuple(int(ch * 2, 16) for ch in value) # type: ignore[return-value]
|
|
84
|
+
if len(value) == 6 and all(ch in "0123456789abcdef" for ch in value):
|
|
85
|
+
return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
|
|
86
|
+
raise ValueError(f"Unknown color: {color!r}")
|
|
87
|
+
if len(color) != 3:
|
|
88
|
+
raise ValueError("RGB colors need exactly three channels")
|
|
89
|
+
rgb = tuple(int(channel) for channel in color)
|
|
90
|
+
if not all(0 <= channel <= 255 for channel in rgb):
|
|
91
|
+
raise ValueError("RGB channels must be between 0 and 255")
|
|
92
|
+
return rgb # type: ignore[return-value]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ansi_fg(color: str | Sequence[int] | RGB) -> str:
|
|
96
|
+
r, g, b = parse_color(color)
|
|
97
|
+
return f"\033[38;2;{r};{g};{b}m"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ansi_bg(color: str | Sequence[int] | RGB) -> str:
|
|
101
|
+
r, g, b = parse_color(color)
|
|
102
|
+
return f"\033[48;2;{r};{g};{b}m"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def strip_ansi(text: str) -> str:
|
|
106
|
+
return ANSI_RE.sub("", text)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def lerp(start: RGBTuple, end: RGBTuple, t: float) -> RGBTuple:
|
|
110
|
+
return tuple(clamp(start[i] + (end[i] - start[i]) * t) for i in range(3)) # type: ignore[return-value]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def blend_many(colors: Sequence[str | Sequence[int] | RGB], steps: int) -> list[RGBTuple]:
|
|
114
|
+
"""Return ``steps`` colors interpolated through all stops."""
|
|
115
|
+
|
|
116
|
+
if steps <= 0:
|
|
117
|
+
return []
|
|
118
|
+
stops = [parse_color(color) for color in colors]
|
|
119
|
+
if len(stops) == 1:
|
|
120
|
+
return stops * steps
|
|
121
|
+
if steps == 1:
|
|
122
|
+
return [stops[0]]
|
|
123
|
+
|
|
124
|
+
result: list[RGBTuple] = []
|
|
125
|
+
segments = len(stops) - 1
|
|
126
|
+
for index in range(steps):
|
|
127
|
+
pos = index / (steps - 1)
|
|
128
|
+
segment = min(segments - 1, int(pos * segments))
|
|
129
|
+
local_start = segment / segments
|
|
130
|
+
local_end = (segment + 1) / segments
|
|
131
|
+
local_t = 0 if local_end == local_start else (pos - local_start) / (local_end - local_start)
|
|
132
|
+
result.append(lerp(stops[segment], stops[segment + 1], local_t))
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def rainbow_color(index: int, total: int, phase: float = 0.0) -> RGBTuple:
|
|
137
|
+
"""Return a smooth rainbow RGB value for ``index`` in ``total``."""
|
|
138
|
+
|
|
139
|
+
if total <= 0:
|
|
140
|
+
return (255, 255, 255)
|
|
141
|
+
angle = 2 * math.pi * ((index / max(total, 1)) + phase)
|
|
142
|
+
r = clamp(math.sin(angle) * 127 + 128)
|
|
143
|
+
g = clamp(math.sin(angle + 2 * math.pi / 3) * 127 + 128)
|
|
144
|
+
b = clamp(math.sin(angle + 4 * math.pi / 3) * 127 + 128)
|
|
145
|
+
return (r, g, b)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cycle_palette(name: str, length: int) -> Iterator[RGBTuple]:
|
|
149
|
+
palette = PALETTES.get(name, PALETTES["rainbow"])
|
|
150
|
+
if length <= 0:
|
|
151
|
+
return iter(())
|
|
152
|
+
return iter(blend_many(palette, length))
|
curint/console.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Rich-powered console wrappers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._compat import Console, Markdown, Panel, Table, Tree
|
|
10
|
+
from .colors import parse_color
|
|
11
|
+
from .text import gradient_text, rainbow_text, style_text
|
|
12
|
+
from .theme import Theme, get_theme
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _rich_color(color: str | Sequence[int] | None) -> str | None:
|
|
16
|
+
if color is None:
|
|
17
|
+
return None
|
|
18
|
+
if isinstance(color, str) and not color.startswith("#"):
|
|
19
|
+
try:
|
|
20
|
+
r, g, b = parse_color(color)
|
|
21
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
22
|
+
except ValueError:
|
|
23
|
+
return color
|
|
24
|
+
r, g, b = parse_color(color)
|
|
25
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SpectraConsole:
|
|
29
|
+
"""Convenience wrapper around :class:`rich.console.Console`."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, theme: str | Theme | None = None, console: Console | None = None) -> None:
|
|
32
|
+
self.theme = get_theme(theme) if isinstance(theme, str) or theme is None else theme
|
|
33
|
+
self.console = console or Console()
|
|
34
|
+
|
|
35
|
+
def print(self, value: Any = "", *, style: str | None = None, markup: bool = True) -> None:
|
|
36
|
+
self.console.print(value, style=style, markup=markup)
|
|
37
|
+
|
|
38
|
+
def gradient(self, text: str, start: str | Sequence[int] | None = None, end: str | Sequence[int] | None = None, *, bold: bool = False) -> None:
|
|
39
|
+
self.console.print(gradient_text(text, start or self.theme.primary, end or self.theme.secondary, bold=bold), markup=False)
|
|
40
|
+
|
|
41
|
+
def rainbow(self, text: str, *, bold: bool = False) -> None:
|
|
42
|
+
self.console.print(rainbow_text(text, bold=bold), markup=False)
|
|
43
|
+
|
|
44
|
+
def banner(self, text: str, *, width: int | None = None, gradient: bool = True) -> str:
|
|
45
|
+
"""Print a compact text banner without external font dependencies."""
|
|
46
|
+
|
|
47
|
+
title = f" {text.strip()} " if text.strip() else " "
|
|
48
|
+
line_width = max(len(title), width or len(title))
|
|
49
|
+
rendered = "═" * line_width + "\n" + title.center(line_width) + "\n" + "═" * line_width
|
|
50
|
+
output = gradient_text(rendered, self.theme.primary, self.theme.secondary, bold=True) if gradient else rendered
|
|
51
|
+
self.console.print(output, markup=False)
|
|
52
|
+
return output
|
|
53
|
+
|
|
54
|
+
def panel(self, text: str, *, title: str | None = None, border: str | Sequence[int] | None = None) -> None:
|
|
55
|
+
border_style = _rich_color(border or self.theme.primary)
|
|
56
|
+
self.console.print(Panel(text, title=title, border_style=border_style))
|
|
57
|
+
|
|
58
|
+
def rule(self, title: str = "", *, color: str | Sequence[int] | None = None) -> None:
|
|
59
|
+
self.console.rule(title, style=_rich_color(color or self.theme.primary))
|
|
60
|
+
|
|
61
|
+
def markdown(self, source: str) -> None:
|
|
62
|
+
self.console.print(Markdown(source))
|
|
63
|
+
|
|
64
|
+
def json(self, data: Any, *, indent: int = 2) -> None:
|
|
65
|
+
self.console.print_json(json.dumps(data, indent=indent, default=str))
|
|
66
|
+
|
|
67
|
+
def table(
|
|
68
|
+
self,
|
|
69
|
+
rows: Iterable[Mapping[str, Any] | Sequence[Any]],
|
|
70
|
+
*,
|
|
71
|
+
headers: Sequence[str] | None = None,
|
|
72
|
+
title: str | None = None,
|
|
73
|
+
) -> Table:
|
|
74
|
+
rows = list(rows)
|
|
75
|
+
if headers is None:
|
|
76
|
+
first = rows[0] if rows else []
|
|
77
|
+
headers = list(first.keys()) if isinstance(first, Mapping) else [f"Column {i + 1}" for i in range(len(first))]
|
|
78
|
+
table = Table(title=title, header_style=_rich_color(self.theme.primary) or "bold")
|
|
79
|
+
for header in headers:
|
|
80
|
+
table.add_column(str(header))
|
|
81
|
+
for row in rows:
|
|
82
|
+
if isinstance(row, Mapping):
|
|
83
|
+
values = [row.get(header, "") for header in headers]
|
|
84
|
+
else:
|
|
85
|
+
values = list(row)
|
|
86
|
+
table.add_row(*(str(value) for value in values))
|
|
87
|
+
self.console.print(table)
|
|
88
|
+
return table
|
|
89
|
+
|
|
90
|
+
def tree(self, root: str, branches: Mapping[str, Any]) -> Tree:
|
|
91
|
+
tree = Tree(root)
|
|
92
|
+
|
|
93
|
+
def add(parent: Tree, value: Any) -> None:
|
|
94
|
+
if isinstance(value, Mapping):
|
|
95
|
+
for key, child in value.items():
|
|
96
|
+
node = parent.add(str(key))
|
|
97
|
+
add(node, child)
|
|
98
|
+
elif isinstance(value, (list, tuple, set)):
|
|
99
|
+
for item in value:
|
|
100
|
+
add(parent, item)
|
|
101
|
+
else:
|
|
102
|
+
parent.add(str(value))
|
|
103
|
+
|
|
104
|
+
add(tree, branches)
|
|
105
|
+
self.console.print(tree)
|
|
106
|
+
return tree
|
|
107
|
+
|
|
108
|
+
def status(self, text: str, kind: str = "info") -> None:
|
|
109
|
+
icon = {
|
|
110
|
+
"success": "✓",
|
|
111
|
+
"error": "✖",
|
|
112
|
+
"warning": "⚠",
|
|
113
|
+
"info": "ℹ",
|
|
114
|
+
"debug": "•",
|
|
115
|
+
}.get(kind, "•")
|
|
116
|
+
color = getattr(self.theme, kind, self.theme.info)
|
|
117
|
+
self.console.print(style_text(f"{icon} {text}", fg=color, bold=True), markup=False)
|
|
118
|
+
|
|
119
|
+
def capture_gradient(self, text: str, start: str | Sequence[int] | None = None, end: str | Sequence[int] | None = None) -> str:
|
|
120
|
+
return gradient_text(text, start or self.theme.primary, end or self.theme.secondary)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_default_console = SpectraConsole()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def success(text: str) -> None:
|
|
127
|
+
_default_console.status(text, "success")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def error(text: str) -> None:
|
|
131
|
+
_default_console.status(text, "error")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def warning(text: str) -> None:
|
|
135
|
+
_default_console.status(text, "warning")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def info(text: str) -> None:
|
|
139
|
+
_default_console.status(text, "info")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def debug(text: str) -> None:
|
|
143
|
+
_default_console.status(text, "debug")
|
curint/logging.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Logging helpers that use Rich output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TextIO
|
|
7
|
+
|
|
8
|
+
from ._compat import Console
|
|
9
|
+
from .theme import get_theme
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SpectraLogHandler(logging.Handler):
|
|
13
|
+
"""A compact, colorful logging handler powered by Rich."""
|
|
14
|
+
|
|
15
|
+
LEVEL_STYLES = {
|
|
16
|
+
logging.DEBUG: "dim",
|
|
17
|
+
logging.INFO: "cyan",
|
|
18
|
+
logging.WARNING: "yellow",
|
|
19
|
+
logging.ERROR: "bold red",
|
|
20
|
+
logging.CRITICAL: "bold white on red",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def __init__(self, *, console: Console | None = None, level: int = logging.NOTSET) -> None:
|
|
24
|
+
super().__init__(level=level)
|
|
25
|
+
self.console = console or Console(stderr=True)
|
|
26
|
+
self.setFormatter(logging.Formatter("%(message)s"))
|
|
27
|
+
|
|
28
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
29
|
+
try:
|
|
30
|
+
message = self.format(record)
|
|
31
|
+
style = self.LEVEL_STYLES.get(record.levelno, "white")
|
|
32
|
+
self.console.print(f"[{record.levelname.lower()}] {message}", style=style)
|
|
33
|
+
except Exception: # pragma: no cover
|
|
34
|
+
self.handleError(record)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configure_logging(level: int | str = logging.INFO, *, logger_name: str | None = None) -> logging.Logger:
|
|
38
|
+
"""Attach a :class:`SpectraLogHandler` and return the configured logger."""
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(logger_name)
|
|
41
|
+
numeric_level = logging.getLevelName(level) if isinstance(level, str) else level
|
|
42
|
+
if not isinstance(numeric_level, int):
|
|
43
|
+
numeric_level = logging.INFO
|
|
44
|
+
logger.setLevel(numeric_level)
|
|
45
|
+
logger.handlers[:] = [SpectraLogHandler(level=numeric_level)]
|
|
46
|
+
logger.propagate = False
|
|
47
|
+
return logger
|
curint/progress.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Progress bars, spinners, and task tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import itertools
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
from typing import TypeVar
|
|
12
|
+
|
|
13
|
+
from ._compat import BarColumn, Console, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, track
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
SPINNER_STYLES: dict[str, tuple[str, ...]] = {
|
|
18
|
+
"dots": ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"),
|
|
19
|
+
"line": ("-", "\\", "|", "/"),
|
|
20
|
+
"circle": ("◐", "◓", "◑", "◒"),
|
|
21
|
+
"moon": ("🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"),
|
|
22
|
+
"bounce": ("⠁", "⠂", "⠄", "⠂"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def progress_bar(total: int = 100, description: str = "Working", *, delay: float = 0.02, transient: bool = False) -> None:
|
|
27
|
+
"""Display a Rich progress bar that advances from 0 to ``total``."""
|
|
28
|
+
|
|
29
|
+
total = max(0, int(total))
|
|
30
|
+
with Progress(
|
|
31
|
+
SpinnerColumn(),
|
|
32
|
+
TextColumn("[progress.description]{task.description}"),
|
|
33
|
+
BarColumn(),
|
|
34
|
+
TextColumn("{task.percentage:>3.0f}%"),
|
|
35
|
+
TimeElapsedColumn(),
|
|
36
|
+
transient=transient,
|
|
37
|
+
) as progress:
|
|
38
|
+
task = progress.add_task(description, total=total)
|
|
39
|
+
while not progress.finished:
|
|
40
|
+
progress.advance(task)
|
|
41
|
+
time.sleep(delay)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Spinner:
|
|
45
|
+
"""Threaded terminal spinner.
|
|
46
|
+
|
|
47
|
+
Use as a context manager:
|
|
48
|
+
|
|
49
|
+
>>> with Spinner("Uploading"):
|
|
50
|
+
... run_upload()
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, message: str = "Working", *, style: str = "dots", delay: float = 0.1, stream=None) -> None:
|
|
54
|
+
self.message = message
|
|
55
|
+
self.frames = SPINNER_STYLES.get(style, SPINNER_STYLES["dots"])
|
|
56
|
+
self.delay = delay
|
|
57
|
+
self.stream = stream or sys.stderr
|
|
58
|
+
self._stop = threading.Event()
|
|
59
|
+
self._thread: threading.Thread | None = None
|
|
60
|
+
|
|
61
|
+
def start(self) -> "Spinner":
|
|
62
|
+
if self._thread and self._thread.is_alive():
|
|
63
|
+
return self
|
|
64
|
+
self._stop.clear()
|
|
65
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
66
|
+
self._thread.start()
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def _spin(self) -> None:
|
|
70
|
+
for frame in itertools.cycle(self.frames):
|
|
71
|
+
if self._stop.is_set():
|
|
72
|
+
break
|
|
73
|
+
self.stream.write(f"\r{frame} {self.message}")
|
|
74
|
+
self.stream.flush()
|
|
75
|
+
time.sleep(self.delay)
|
|
76
|
+
|
|
77
|
+
def stop(self, final: str | None = None) -> None:
|
|
78
|
+
self._stop.set()
|
|
79
|
+
if self._thread:
|
|
80
|
+
self._thread.join(timeout=self.delay * 3)
|
|
81
|
+
clear = " " * (len(self.message) + 8)
|
|
82
|
+
self.stream.write(f"\r{clear}\r")
|
|
83
|
+
if final:
|
|
84
|
+
self.stream.write(final + "\n")
|
|
85
|
+
self.stream.flush()
|
|
86
|
+
|
|
87
|
+
def __enter__(self) -> "Spinner":
|
|
88
|
+
return self.start()
|
|
89
|
+
|
|
90
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
91
|
+
self.stop()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@contextlib.contextmanager
|
|
95
|
+
def rich_status(message: str, *, spinner: str = "dots") -> Iterator[None]:
|
|
96
|
+
console = Console()
|
|
97
|
+
with console.status(message, spinner=spinner):
|
|
98
|
+
yield
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def track_iter(iterable: Iterable[T], description: str = "Processing") -> Iterator[T]:
|
|
102
|
+
yield from track(iterable, description=description)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Convenience aliases.
|
|
106
|
+
progress_bar_rich = progress_bar
|
|
107
|
+
SpinnerRich = Spinner
|
curint/prompt.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Tiny prompt helpers styled with curint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from .text import style_text
|
|
8
|
+
from .theme import get_theme
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ask(question: str, *, default: str | None = None) -> str:
|
|
12
|
+
theme = get_theme()
|
|
13
|
+
suffix = f" [{default}]" if default is not None else ""
|
|
14
|
+
answer = input(style_text(f"? {question}{suffix}: ", fg=theme.primary, bold=True))
|
|
15
|
+
return answer or (default or "")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def confirm(question: str, *, default: bool = False) -> bool:
|
|
19
|
+
marker = "Y/n" if default else "y/N"
|
|
20
|
+
answer = ask(f"{question} ({marker})").strip().lower()
|
|
21
|
+
if not answer:
|
|
22
|
+
return default
|
|
23
|
+
return answer in {"y", "yes", "true", "1"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def choose(question: str, choices: Sequence[str], *, default: str | None = None) -> str:
|
|
27
|
+
if not choices:
|
|
28
|
+
raise ValueError("choices cannot be empty")
|
|
29
|
+
default = default or choices[0]
|
|
30
|
+
print(style_text(f"? {question}", fg=get_theme().primary, bold=True))
|
|
31
|
+
for index, choice in enumerate(choices, start=1):
|
|
32
|
+
print(f" {index}. {choice}")
|
|
33
|
+
answer = ask("Choose a number", default=str(choices.index(default) + 1))
|
|
34
|
+
try:
|
|
35
|
+
position = int(answer) - 1
|
|
36
|
+
return choices[position]
|
|
37
|
+
except Exception:
|
|
38
|
+
return default
|
curint/text.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Text styling helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ._compat import Console, SKYTEXT, colored, just_fix_windows_console
|
|
9
|
+
from .colors import PALETTES, RESET, RGB, ansi_bg, ansi_fg, blend_many, parse_color, rainbow_color, strip_ansi
|
|
10
|
+
|
|
11
|
+
just_fix_windows_console()
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def style_text(
|
|
16
|
+
text: str,
|
|
17
|
+
fg: str | Sequence[int] | RGB | None = None,
|
|
18
|
+
bg: str | Sequence[int] | RGB | None = None,
|
|
19
|
+
*,
|
|
20
|
+
bold: bool = False,
|
|
21
|
+
italic: bool = False,
|
|
22
|
+
underline: bool = False,
|
|
23
|
+
dim: bool = False,
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Return ANSI-styled text with true-color foreground/background support."""
|
|
26
|
+
|
|
27
|
+
codes: list[str] = []
|
|
28
|
+
if bold:
|
|
29
|
+
codes.append("\033[1m")
|
|
30
|
+
if dim:
|
|
31
|
+
codes.append("\033[2m")
|
|
32
|
+
if italic:
|
|
33
|
+
codes.append("\033[3m")
|
|
34
|
+
if underline:
|
|
35
|
+
codes.append("\033[4m")
|
|
36
|
+
if fg is not None:
|
|
37
|
+
codes.append(ansi_fg(fg))
|
|
38
|
+
if bg is not None:
|
|
39
|
+
codes.append(ansi_bg(bg))
|
|
40
|
+
if not codes:
|
|
41
|
+
return text
|
|
42
|
+
return "".join(codes) + text + RESET
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def termcolor_text(text: str, color: str | None = None, on_color: str | None = None, attrs: list[str] | None = None) -> str:
|
|
46
|
+
"""Small wrapper around :func:`termcolor.colored` for classic ANSI names."""
|
|
47
|
+
|
|
48
|
+
return colored(text, color=color, on_color=on_color, attrs=attrs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def gradient_text(
|
|
52
|
+
text: str,
|
|
53
|
+
start: str | Sequence[int] | RGB = "#ff5e62",
|
|
54
|
+
end: str | Sequence[int] | RGB = "#00d4ff",
|
|
55
|
+
*,
|
|
56
|
+
stops: Sequence[str | Sequence[int] | RGB] | None = None,
|
|
57
|
+
bold: bool = False,
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Return text colored by a smooth RGB gradient."""
|
|
60
|
+
|
|
61
|
+
chars = list(text)
|
|
62
|
+
colors = blend_many(stops or [start, end], len(chars))
|
|
63
|
+
pieces: list[str] = []
|
|
64
|
+
for char, color in zip(chars, colors, strict=False):
|
|
65
|
+
if char.isspace():
|
|
66
|
+
pieces.append(char)
|
|
67
|
+
else:
|
|
68
|
+
pieces.append(style_text(char, color, bold=bold))
|
|
69
|
+
return "".join(pieces)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def palette_text(text: str, palette: str = "rainbow", *, bold: bool = False) -> str:
|
|
73
|
+
colors = blend_many(PALETTES.get(palette, PALETTES["rainbow"]), len(text))
|
|
74
|
+
return "".join(char if char.isspace() else style_text(char, color, bold=bold) for char, color in zip(text, colors, strict=False))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def rainbow_text(text: str, *, phase: float = 0.0, bold: bool = False) -> str:
|
|
78
|
+
pieces: list[str] = []
|
|
79
|
+
total = max(1, len(text))
|
|
80
|
+
for index, char in enumerate(text):
|
|
81
|
+
if char.isspace():
|
|
82
|
+
pieces.append(char)
|
|
83
|
+
else:
|
|
84
|
+
pieces.append(style_text(char, rainbow_color(index, total, phase), bold=bold))
|
|
85
|
+
return "".join(pieces)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def highlight(text: str, query: str, *, fg: str | Sequence[int] | RGB = "black", bg: str | Sequence[int] | RGB = "yellow") -> str:
|
|
89
|
+
"""Highlight all exact occurrences of ``query`` in ``text``."""
|
|
90
|
+
|
|
91
|
+
if not query:
|
|
92
|
+
return text
|
|
93
|
+
return text.replace(query, style_text(query, fg, bg, bold=True))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def center(text: str, width: int = 80, fill: str = " ") -> str:
|
|
97
|
+
"""Center text using a simple ANSI-stripped display width."""
|
|
98
|
+
|
|
99
|
+
display = max(0, len(strip_ansi(text)))
|
|
100
|
+
if display >= width:
|
|
101
|
+
return text
|
|
102
|
+
total = width - display
|
|
103
|
+
left = total // 2
|
|
104
|
+
right = total - left
|
|
105
|
+
return f"{fill * left}{text}{fill * right}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def box(text: str, *, title: str | None = None, width: int | None = None) -> str:
|
|
109
|
+
"""Return a simple ANSI-free box string for logs or plain terminals."""
|
|
110
|
+
|
|
111
|
+
lines = text.splitlines() or [""]
|
|
112
|
+
content_width = max(len(strip_ansi(line)) for line in lines)
|
|
113
|
+
if title:
|
|
114
|
+
content_width = max(content_width, len(title) + 2)
|
|
115
|
+
if width is not None:
|
|
116
|
+
content_width = max(content_width, width - 4)
|
|
117
|
+
top_label = f" {title} " if title else ""
|
|
118
|
+
top = "┌" + top_label + "─" * max(0, content_width - len(top_label)) + "┐"
|
|
119
|
+
bottom = "└" + "─" * content_width + "┘"
|
|
120
|
+
body = ["│" + line + " " * max(0, content_width - len(strip_ansi(line))) + "│" for line in lines]
|
|
121
|
+
return "\n".join([top, *body, bottom])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def sky_gradient(text: str, start: tuple[int, int, int] = (255, 0, 0), end: tuple[int, int, int] = (0, 255, 255)) -> str:
|
|
125
|
+
"""Use SkyText's gradient when available, otherwise use curint's fallback."""
|
|
126
|
+
|
|
127
|
+
if SKYTEXT is not None:
|
|
128
|
+
fn = getattr(SKYTEXT, "gradient", None) or getattr(SKYTEXT, "print_gradient", None)
|
|
129
|
+
if callable(fn):
|
|
130
|
+
# Most SkyText helpers print directly. We return our fallback to keep this function pure.
|
|
131
|
+
return gradient_text(text, start, end)
|
|
132
|
+
return gradient_text(text, start, end)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def print_color(text: str, fg: str | Sequence[int] | RGB | None = None, bg: str | Sequence[int] | RGB | None = None, **kwargs: Any) -> None:
|
|
136
|
+
console.print(style_text(text, fg=fg, bg=bg, **kwargs), markup=False)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def print_gradient(text: str, start: str | Sequence[int] | RGB = "#ff5e62", end: str | Sequence[int] | RGB = "#00d4ff", **kwargs: Any) -> None:
|
|
140
|
+
console.print(gradient_text(text, start, end, **kwargs), markup=False)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def print_rainbow(text: str, **kwargs: Any) -> None:
|
|
144
|
+
console.print(rainbow_text(text, **kwargs), markup=False)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# Convenience aliases for callers who prefer print-oriented names.
|
|
148
|
+
color_text = style_text
|
|
149
|
+
cprint = print_color
|
|
150
|
+
print_gradient_rich = print_gradient
|
curint/theme.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Theme definitions used by console and status helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
ColorLike = str | tuple[int, int, int]
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Theme:
|
|
12
|
+
name: str
|
|
13
|
+
primary: ColorLike = "#00d4ff"
|
|
14
|
+
secondary: ColorLike = "#ff5e62"
|
|
15
|
+
accent: ColorLike = "#ffd166"
|
|
16
|
+
success: ColorLike = "#50fa7b"
|
|
17
|
+
error: ColorLike = "#ff5555"
|
|
18
|
+
warning: ColorLike = "#f1fa8c"
|
|
19
|
+
info: ColorLike = "#8be9fd"
|
|
20
|
+
muted: ColorLike = "#6272a4"
|
|
21
|
+
|
|
22
|
+
def as_dict(self) -> dict[str, ColorLike]:
|
|
23
|
+
return {
|
|
24
|
+
"primary": self.primary,
|
|
25
|
+
"secondary": self.secondary,
|
|
26
|
+
"accent": self.accent,
|
|
27
|
+
"success": self.success,
|
|
28
|
+
"error": self.error,
|
|
29
|
+
"warning": self.warning,
|
|
30
|
+
"info": self.info,
|
|
31
|
+
"muted": self.muted,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def with_values(self, **values: ColorLike) -> "Theme":
|
|
35
|
+
return replace(self, **values)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
THEMES: dict[str, Theme] = {
|
|
39
|
+
"default": Theme("default"),
|
|
40
|
+
"dracula": Theme(
|
|
41
|
+
"dracula",
|
|
42
|
+
primary="#bd93f9",
|
|
43
|
+
secondary="#ff79c6",
|
|
44
|
+
accent="#f1fa8c",
|
|
45
|
+
success="#50fa7b",
|
|
46
|
+
error="#ff5555",
|
|
47
|
+
warning="#ffb86c",
|
|
48
|
+
info="#8be9fd",
|
|
49
|
+
muted="#6272a4",
|
|
50
|
+
),
|
|
51
|
+
"ocean": Theme(
|
|
52
|
+
"ocean",
|
|
53
|
+
primary="#00b4db",
|
|
54
|
+
secondary="#0083b0",
|
|
55
|
+
accent="#caf0f8",
|
|
56
|
+
success="#80ed99",
|
|
57
|
+
error="#ff6b6b",
|
|
58
|
+
warning="#ffd166",
|
|
59
|
+
info="#90e0ef",
|
|
60
|
+
muted="#6c757d",
|
|
61
|
+
),
|
|
62
|
+
"sunset": Theme(
|
|
63
|
+
"sunset",
|
|
64
|
+
primary="#ff5e62",
|
|
65
|
+
secondary="#ff9966",
|
|
66
|
+
accent="#ffe66d",
|
|
67
|
+
success="#96f550",
|
|
68
|
+
error="#ff3864",
|
|
69
|
+
warning="#ffcc00",
|
|
70
|
+
info="#a0e7e5",
|
|
71
|
+
muted="#6d6875",
|
|
72
|
+
),
|
|
73
|
+
"mono": Theme(
|
|
74
|
+
"mono",
|
|
75
|
+
primary="white",
|
|
76
|
+
secondary="gray",
|
|
77
|
+
accent="white",
|
|
78
|
+
success="white",
|
|
79
|
+
error="white",
|
|
80
|
+
warning="white",
|
|
81
|
+
info="white",
|
|
82
|
+
muted="gray",
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_current_theme = THEMES["default"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_theme(name: str | None = None) -> Theme:
|
|
90
|
+
if name is None:
|
|
91
|
+
return _current_theme
|
|
92
|
+
try:
|
|
93
|
+
return THEMES[name]
|
|
94
|
+
except KeyError as exc:
|
|
95
|
+
available = ", ".join(sorted(THEMES))
|
|
96
|
+
raise ValueError(f"Unknown theme {name!r}. Available themes: {available}") from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def use_theme(name: str) -> Theme:
|
|
100
|
+
global _current_theme
|
|
101
|
+
_current_theme = get_theme(name)
|
|
102
|
+
return _current_theme
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def register_theme(theme: Theme, *, replace_existing: bool = False) -> Theme:
|
|
106
|
+
if theme.name in THEMES and not replace_existing:
|
|
107
|
+
raise ValueError(f"Theme {theme.name!r} already exists")
|
|
108
|
+
THEMES[theme.name] = theme
|
|
109
|
+
return theme
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def theme_names() -> list[str]:
|
|
113
|
+
return sorted(THEMES)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def theme_from_mapping(name: str, mapping: Mapping[str, ColorLike]) -> Theme:
|
|
117
|
+
base = Theme(name)
|
|
118
|
+
valid = {field: value for field, value in mapping.items() if field in base.as_dict()}
|
|
119
|
+
return base.with_values(**valid)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 curint contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: curint
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Colorful terminal output toolkit
|
|
5
|
+
Author: corotosh
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: terminal,colors,rich,ansi,gradient,animation
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Classifier: Topic :: Terminals
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: colorama
|
|
21
|
+
Requires-Dist: termcolor
|
|
22
|
+
Requires-Dist: skytext
|
|
23
|
+
Requires-Dist: rich
|
|
24
|
+
|
|
25
|
+
curint is a Python library for colorful terminal output. It provides styled text, RGB gradients, rainbow text, themed console helpers, Rich panels and tables, status messages, prompts, logging, spinners, progress bars, and lightweight text animations.
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- True-color foreground and background styling.
|
|
30
|
+
- Gradient, palette, and rainbow text.
|
|
31
|
+
- Built-in themes such as `default`, `sunset`, `ocean`, `dracula`, and `forest`.
|
|
32
|
+
- Rich-backed panels, tables, trees, rules, Markdown, and JSON output.
|
|
33
|
+
- Status helpers: `success()`, `error()`, `warning()`, `info()`, and `debug()`.
|
|
34
|
+
- Simple prompts: `ask()`, `confirm()`, and `choose()`.
|
|
35
|
+
- Spinners, progress bars, and iterable tracking.
|
|
36
|
+
- Text effects: typewriter, wave, bounce, glitch, and shimmer.
|
|
37
|
+
- A colorful `logging.Handler` for terminal applications.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from curint import SpectraConsole, Spinner, progress_bar, success
|
|
43
|
+
|
|
44
|
+
console = SpectraConsole(theme="sunset")
|
|
45
|
+
console.banner("curint")
|
|
46
|
+
console.gradient("Gradient text with a theme", bold=True)
|
|
47
|
+
console.rainbow("Rainbow output", bold=True)
|
|
48
|
+
console.panel("Useful for build scripts, demos, and terminal applications.", title="Hello")
|
|
49
|
+
|
|
50
|
+
with Spinner("Working"):
|
|
51
|
+
import time
|
|
52
|
+
time.sleep(0.5)
|
|
53
|
+
|
|
54
|
+
progress_bar(25, "Finishing", delay=0.01)
|
|
55
|
+
success("Done")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Text helpers
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from curint import gradient_text, highlight, palette_text, rainbow_text, style_text
|
|
62
|
+
|
|
63
|
+
print(style_text("bold cyan", fg="cyan", bold=True))
|
|
64
|
+
print(gradient_text("sunset gradient", "#ff5e62", "#ffd166"))
|
|
65
|
+
print(rainbow_text("rainbow"))
|
|
66
|
+
print(palette_text("neon", "neon"))
|
|
67
|
+
print(highlight("ship the package", "ship"))
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Console helpers
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from curint import SpectraConsole
|
|
74
|
+
|
|
75
|
+
console = SpectraConsole(theme="ocean")
|
|
76
|
+
console.banner("release")
|
|
77
|
+
console.rule("checks")
|
|
78
|
+
console.panel("All systems ready", title="Status")
|
|
79
|
+
console.table(
|
|
80
|
+
[
|
|
81
|
+
{"Check": "tests", "Result": "passed"},
|
|
82
|
+
{"Check": "build", "Result": "passed"},
|
|
83
|
+
],
|
|
84
|
+
title="Summary",
|
|
85
|
+
)
|
|
86
|
+
console.tree("package", {"dist": ["sdist", "wheel"], "docs": ["README"]})
|
|
87
|
+
console.markdown("**Markdown** rendering via Rich")
|
|
88
|
+
console.json({"ok": True, "version": "0.1.2"})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Themes
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from curint import Theme, register_theme, theme_names, use_theme
|
|
95
|
+
|
|
96
|
+
register_theme(
|
|
97
|
+
Theme(
|
|
98
|
+
name="brand",
|
|
99
|
+
primary="#7c3aed",
|
|
100
|
+
secondary="#06b6d4",
|
|
101
|
+
accent="#f59e0b",
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
console = use_theme("brand")
|
|
106
|
+
console.gradient("Custom brand theme")
|
|
107
|
+
print(theme_names())
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Animations
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from curint import bounce, glitch, shimmer, typewriter, wave
|
|
114
|
+
|
|
115
|
+
typewriter("Typed output", delay=0.03)
|
|
116
|
+
wave("wave text")
|
|
117
|
+
bounce("bounce text")
|
|
118
|
+
glitch("glitch text")
|
|
119
|
+
shimmer("shimmer text")
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
For tests, use frame helpers that return strings instead of printing:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from curint import glitch_frames, wave_frames
|
|
126
|
+
|
|
127
|
+
frames = wave_frames("abc", frames=3)
|
|
128
|
+
assert len(frames) == 3
|
|
129
|
+
assert glitch_frames("abc", frames=2)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Progress and spinners
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from curint import Spinner, progress_bar, rich_status, track_iter
|
|
136
|
+
|
|
137
|
+
with Spinner("Uploading"):
|
|
138
|
+
import time
|
|
139
|
+
time.sleep(0.5)
|
|
140
|
+
|
|
141
|
+
progress_bar(50, "Building", delay=0.01)
|
|
142
|
+
|
|
143
|
+
with rich_status("Indexing"):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
for item in track_iter(range(10), "Processing"):
|
|
147
|
+
pass
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Logging
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from curint import configure_logging
|
|
154
|
+
|
|
155
|
+
logger = configure_logging("INFO", logger_name="curint-demo")
|
|
156
|
+
logger.info("Colorful logging is ready")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Prompts
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from curint import ask, choose, confirm
|
|
163
|
+
|
|
164
|
+
name = ask("Project name", default="demo")
|
|
165
|
+
color = choose("Theme", ["sunset", "ocean", "dracula"], default="ocean")
|
|
166
|
+
ready = confirm("Continue?", default=True)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT License.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
curint/__init__.py,sha256=V1nnIV9-Tiehl6rPZoRrGknsd25oNHIP9ivXHw255Kw,2027
|
|
2
|
+
curint/_compat.py,sha256=REIu4qTuEZ6dGoqJdySNqijCrKSTvh6BLqEP2zkQJmc,1620
|
|
3
|
+
curint/animations.py,sha256=Lx_7ULdWY9HuVH2PinX8m3xnwm57g4MaBlzk5mVsGqY,3859
|
|
4
|
+
curint/colors.py,sha256=-hwmkYt0EWRZHVr157jxXMeu74AlbhHSHHtU97J59Pc,4935
|
|
5
|
+
curint/console.py,sha256=3vDvw1A03kiAvdoOdWC1FB84gVGb-NcJd_11au-8iwE,5413
|
|
6
|
+
curint/logging.py,sha256=vAtAqZpLUh9h6qyzAwAORtA0R5jAzCIvQfUM0OLUPy4,1640
|
|
7
|
+
curint/progress.py,sha256=ElqEf5MjWb4AamTR8jkGn1e1hf9JvtvjjVpCA9KRM_g,3289
|
|
8
|
+
curint/prompt.py,sha256=0OLWL228ZOGqehCpy58hCYH79fVnN2IsB8i_ui5Zv0A,1268
|
|
9
|
+
curint/text.py,sha256=Osfs5ybGtnnyBf5sQ8pLiDsHUSs6K6uQO3Dyhs-PIYc,5321
|
|
10
|
+
curint/theme.py,sha256=4g0mvtWYo9_JqXRhJVk1dw9RSwYP9I3BMXyXceJyMOQ,3063
|
|
11
|
+
curint-0.1.2.dist-info/LICENSE,sha256=5Ai5gNurXlg9oUX2qebY4Uw1XBvkB13Eabp5xsBBgYg,1076
|
|
12
|
+
curint-0.1.2.dist-info/METADATA,sha256=dNe3JfxTXEJSBV4qFriFSnRg0qP1Shu3BuDfEvxdjnk,4600
|
|
13
|
+
curint-0.1.2.dist-info/WHEEL,sha256=51RkbunBAw4BWsgaQWTpPhg4Diwp3c9P5iaLk67Hdtg,92
|
|
14
|
+
curint-0.1.2.dist-info/top_level.txt,sha256=-rNbEsJbMaVg0mNvGg7mNd6mGsDRvOpzDEKR7XodMUM,7
|
|
15
|
+
curint-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
curint
|