CharmPkk 0.4.0__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.
charmpkk/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """CharmPkk — красивые интерфейсы для Python: консоль и окно."""
2
+ from .version import __version__
3
+ from . import console
4
+
5
+ __all__ = ["console", "__version__"]
6
+
7
+ try:
8
+ from . import window # noqa: F401
9
+ __all__.append("window")
10
+ except Exception:
11
+ # tkinter может отсутствовать (например, headless-сервер)
12
+ pass
@@ -0,0 +1,95 @@
1
+ from . import colors, text, boxes, layout, widgets, charts, menu
2
+ from . import anim, particles, effects, live as _live_mod
3
+ from . import terminal as terminal_mod
4
+ from . import themes as console_themes
5
+ from . import banner as banner_mod
6
+ from . import forms as forms_mod
7
+ from . import wizard as wizard_mod
8
+ from . import markdown as markdown_mod
9
+ from . import progress_ext
10
+ from .colors import (
11
+ colorize, fg_rgb, bg_rgb, fg_hex, bg_hex, fg_256, bg_256,
12
+ gradient_text, rainbow_text, Style,
13
+ enable, disable, is_enabled, strip_ansi,
14
+ BOLD, DIM, ITALIC, UNDERLINE, BLINK, REVERSE, HIDDEN, STRIKE, RESET,
15
+ BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE,
16
+ BRIGHT_BLACK, BRIGHT_RED, BRIGHT_GREEN, BRIGHT_YELLOW,
17
+ BRIGHT_BLUE, BRIGHT_MAGENTA, BRIGHT_CYAN, BRIGHT_WHITE,
18
+ )
19
+ from .text import wrap, align, center, pad, truncate, typewriter, indent, visible_len
20
+ from .boxes import box, rule, section, panel, banner, STYLES
21
+ from .layout import columns, rows, grid, sidebar
22
+ from .widgets import (
23
+ ProgressBar, MultiProgress, Spinner, Table,
24
+ bullet_list, numbered_list, key_value, badge, statusline, tree,
25
+ notify, info, success, warning, error, Log,
26
+ ask, ask_password, ask_number, confirm, ask_choice,
27
+ )
28
+ from .charts import sparkline, bar_chart, gauge, histogram, heatmap
29
+ from .menu import select, multiselect, tabs
30
+
31
+ from .anim import (
32
+ ease, tween, lerp, lerp_color, Transition, EASINGS,
33
+ ease_in_quad, ease_out_quad, ease_in_out_quad,
34
+ ease_in_cubic, ease_out_cubic, ease_in_out_cubic,
35
+ ease_in_quart, ease_out_quart, ease_in_out_sine,
36
+ ease_in_expo, ease_out_expo,
37
+ ease_out_back, ease_out_elastic, ease_out_bounce,
38
+ )
39
+ from .particles import (
40
+ Canvas as ParticleCanvas, ParticleSystem,
41
+ Rain, Snow, Stars, Dust, Matrix, Bubbles, Fireflies,
42
+ play as play_particles, play_preset, PRESETS as PARTICLE_PRESETS,
43
+ )
44
+ from .effects import (
45
+ typewriter as typewriter_fx, fade_in, wave, shimmer,
46
+ glitch, marquee, neon,
47
+ )
48
+ from .live import Live, animate_to
49
+
50
+ from .terminal import Terminal, terminal
51
+ from .themes import Palette, REGISTRY as THEMES, get as get_theme, list_names as list_themes
52
+ from .banner import big as big_text
53
+ from .forms import Form, Field
54
+ from .wizard import Wizard, Step
55
+ from .markdown import render as render_markdown, print_md
56
+ from .progress_ext import FancyProgressBar, AnimatedProgressBar, TaskList
57
+
58
+ __all__ = [
59
+ "colors", "colorize", "fg_rgb", "bg_rgb", "fg_hex", "bg_hex", "fg_256", "bg_256",
60
+ "gradient_text", "rainbow_text", "Style", "enable", "disable", "is_enabled",
61
+ "strip_ansi",
62
+ "BOLD", "DIM", "ITALIC", "UNDERLINE", "BLINK", "REVERSE", "HIDDEN", "STRIKE", "RESET",
63
+ "BLACK", "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE",
64
+ "BRIGHT_BLACK", "BRIGHT_RED", "BRIGHT_GREEN", "BRIGHT_YELLOW",
65
+ "BRIGHT_BLUE", "BRIGHT_MAGENTA", "BRIGHT_CYAN", "BRIGHT_WHITE",
66
+ "text", "wrap", "align", "center", "pad", "truncate", "typewriter", "indent", "visible_len",
67
+ "boxes", "box", "rule", "section", "panel", "banner", "STYLES",
68
+ "layout", "columns", "rows", "grid", "sidebar",
69
+ "widgets", "ProgressBar", "MultiProgress", "Spinner", "Table",
70
+ "bullet_list", "numbered_list", "key_value", "badge", "statusline", "tree",
71
+ "notify", "info", "success", "warning", "error", "Log",
72
+ "ask", "ask_password", "ask_number", "confirm", "ask_choice",
73
+ "charts", "sparkline", "bar_chart", "gauge", "histogram", "heatmap",
74
+ "menu", "select", "multiselect", "tabs",
75
+ "anim", "ease", "tween", "lerp", "lerp_color", "Transition", "EASINGS",
76
+ "ease_in_quad", "ease_out_quad", "ease_in_out_quad",
77
+ "ease_in_cubic", "ease_out_cubic", "ease_in_out_cubic",
78
+ "ease_in_quart", "ease_out_quart", "ease_in_out_sine",
79
+ "ease_in_expo", "ease_out_expo",
80
+ "ease_out_back", "ease_out_elastic", "ease_out_bounce",
81
+ "particles", "ParticleCanvas", "ParticleSystem",
82
+ "Rain", "Snow", "Stars", "Dust", "Matrix", "Bubbles", "Fireflies",
83
+ "play_particles", "play_preset", "PARTICLE_PRESETS",
84
+ "effects", "typewriter_fx", "fade_in", "wave", "shimmer",
85
+ "glitch", "marquee", "neon",
86
+ "Live", "animate_to",
87
+ # 0.4.0
88
+ "terminal_mod", "Terminal", "terminal",
89
+ "console_themes", "Palette", "THEMES", "get_theme", "list_themes",
90
+ "banner_mod", "big_text",
91
+ "forms_mod", "Form", "Field",
92
+ "wizard_mod", "Wizard", "Step",
93
+ "markdown_mod", "render_markdown", "print_md",
94
+ "progress_ext", "FancyProgressBar", "AnimatedProgressBar", "TaskList",
95
+ ]
@@ -0,0 +1,120 @@
1
+ """Easing, tween, интерполяция — для плавных консольных анимаций."""
2
+ import math
3
+
4
+
5
+ # ---------- easing ----------
6
+
7
+ def linear(t): return t
8
+ def ease_in_quad(t): return t * t
9
+ def ease_out_quad(t): return 1 - (1 - t) ** 2
10
+ def ease_in_out_quad(t): return 2 * t * t if t < 0.5 else 1 - (-2 * t + 2) ** 2 / 2
11
+ def ease_in_cubic(t): return t ** 3
12
+ def ease_out_cubic(t): return 1 - (1 - t) ** 3
13
+ def ease_in_out_cubic(t): return 4 * t ** 3 if t < 0.5 else 1 - (-2 * t + 2) ** 3 / 2
14
+ def ease_in_quart(t): return t ** 4
15
+ def ease_out_quart(t): return 1 - (1 - t) ** 4
16
+ def ease_in_out_sine(t): return -(math.cos(math.pi * t) - 1) / 2
17
+ def ease_in_expo(t): return 0 if t == 0 else 2 ** (10 * t - 10)
18
+ def ease_out_expo(t): return 1 if t == 1 else 1 - 2 ** (-10 * t)
19
+
20
+
21
+ def ease_out_back(t):
22
+ c1, c3 = 1.70158, 2.70158
23
+ return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2
24
+
25
+
26
+ def ease_out_elastic(t):
27
+ if t in (0.0, 1.0):
28
+ return t
29
+ c4 = (2 * math.pi) / 3
30
+ return 2 ** (-10 * t) * math.sin((t * 10 - 0.75) * c4) + 1
31
+
32
+
33
+ def ease_out_bounce(t):
34
+ n1, d1 = 7.5625, 2.75
35
+ if t < 1 / d1:
36
+ return n1 * t * t
37
+ if t < 2 / d1:
38
+ t -= 1.5 / d1
39
+ return n1 * t * t + 0.75
40
+ if t < 2.5 / d1:
41
+ t -= 2.25 / d1
42
+ return n1 * t * t + 0.9375
43
+ t -= 2.625 / d1
44
+ return n1 * t * t + 0.984375
45
+
46
+
47
+ EASINGS = {
48
+ "linear": linear,
49
+ "in_quad": ease_in_quad, "out_quad": ease_out_quad, "in_out_quad": ease_in_out_quad,
50
+ "in_cubic": ease_in_cubic, "out_cubic": ease_out_cubic, "in_out_cubic": ease_in_out_cubic,
51
+ "in_quart": ease_in_quart, "out_quart": ease_out_quart,
52
+ "in_out_sine": ease_in_out_sine,
53
+ "in_expo": ease_in_expo, "out_expo": ease_out_expo,
54
+ "out_back": ease_out_back, "out_elastic": ease_out_elastic, "out_bounce": ease_out_bounce,
55
+ }
56
+
57
+
58
+ def ease(t, kind="out_cubic"):
59
+ return EASINGS.get(kind, ease_out_cubic)(max(0.0, min(1.0, t)))
60
+
61
+
62
+ def tween(duration=1.0, fps=30, kind="out_cubic"):
63
+ """Генератор значений 0..1 (с плавным easing)."""
64
+ n = max(int(duration * fps), 1)
65
+ for i in range(n + 1):
66
+ yield ease(i / n, kind)
67
+
68
+
69
+ def lerp(a, b, t):
70
+ return a + (b - a) * t
71
+
72
+
73
+ def _hex_to_rgb(h):
74
+ h = h.lstrip("#")
75
+ if len(h) == 3:
76
+ h = "".join(c * 2 for c in h)
77
+ return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
78
+
79
+
80
+ def _rgb_to_hex(rgb):
81
+ return "#{:02x}{:02x}{:02x}".format(*(max(0, min(255, int(c))) for c in rgb))
82
+
83
+
84
+ def lerp_color(c1, c2, t):
85
+ """c1, c2 — hex-строки, возвращает hex."""
86
+ a, b = _hex_to_rgb(c1), _hex_to_rgb(c2)
87
+ return _rgb_to_hex((a[i] + (b[i] - a[i]) * t for i in range(3)))
88
+
89
+
90
+ class Transition:
91
+ """Плавно переводит число из текущего значения в целевое."""
92
+
93
+ def __init__(self, value=0.0, kind="out_cubic"):
94
+ self.value = value
95
+ self.target = value
96
+ self.start = value
97
+ self.kind = kind
98
+ self._t = 0.0
99
+ self._duration = 0.0
100
+
101
+ def to(self, target, duration=0.5, kind=None):
102
+ self.start = self.value
103
+ self.target = target
104
+ self._duration = max(duration, 1e-6)
105
+ self._t = 0.0
106
+ if kind:
107
+ self.kind = kind
108
+ return self
109
+
110
+ def step(self, dt):
111
+ if self._duration <= 0:
112
+ self.value = self.target
113
+ return self.value
114
+ self._t = min(self._t + dt / self._duration, 1.0)
115
+ self.value = lerp(self.start, self.target, ease(self._t, self.kind))
116
+ return self.value
117
+
118
+ @property
119
+ def done(self):
120
+ return self._t >= 1.0
@@ -0,0 +1,80 @@
1
+ """Большие ASCII-заголовки (минимальный FIGlet-подобный рендер)."""
2
+ from .colors import colorize, gradient_text
3
+
4
+ # 5-строчные глифы (только заглавные + цифры + базовые знаки).
5
+ _FONT = {
6
+ "A": [" ▄▀█ ", " █▀█ ", " ▀ ▀ "],
7
+ "B": [" █▀▄ ", " █▀▄ ", " ▀▀ "],
8
+ "C": [" █▀▀ ", " █ ", " ▀▀▀ "],
9
+ "D": [" █▀▄ ", " █ █ ", " ▀▀ "],
10
+ "E": [" █▀▀ ", " █▀ ", " ▀▀▀ "],
11
+ "F": [" █▀▀ ", " █▀ ", " ▀ "],
12
+ "G": [" █▀▀ ", " █ █ ", " ▀▀▀ "],
13
+ "H": [" █ █ ", " █▀█ ", " ▀ ▀ "],
14
+ "I": [" █ ", " █ ", " ▀ "],
15
+ "J": [" █ ", " █ ", " ▀▀ "],
16
+ "K": [" █ █ ", " █▀▄ ", " ▀ ▀ "],
17
+ "L": [" █ ", " █ ", " ▀▀▀ "],
18
+ "M": [" █▀▄▀█ ", " █ ▀ █ ", " ▀ ▀ "],
19
+ "N": [" █▄ █ ", " █ ▀█ ", " ▀ ▀ "],
20
+ "O": [" █▀█ ", " █ █ ", " ▀▀▀ "],
21
+ "P": [" █▀█ ", " █▀▀ ", " ▀ "],
22
+ "Q": [" █▀█ ", " █ █ ", " ▀▀▀▄ "],
23
+ "R": [" █▀▄ ", " █▀▄ ", " ▀ ▀ "],
24
+ "S": [" █▀▀ ", " ▀▀█ ", " ▀▀▀ "],
25
+ "T": [" █▀█ ", " █ ", " ▀ "],
26
+ "U": [" █ █ ", " █ █ ", " ▀▀▀ "],
27
+ "V": [" █ █ ", " █ █ ", " ▀ "],
28
+ "W": [" █ █ █ ", " █ █ █ ", " ▀ ▀ "],
29
+ "X": [" █ █ ", " █ ", " ▀ ▀ "],
30
+ "Y": [" █ █ ", " █ ", " ▀ "],
31
+ "Z": [" ▀▀█ ", " ▄▀ ", " ▀▀▀ "],
32
+ "0": [" █▀█ ", " █ █ ", " ▀▀▀ "],
33
+ "1": [" ▄█ ", " █ ", " ▀▀▀ "],
34
+ "2": [" █▀█ ", " ▄▀ ", " ▀▀▀ "],
35
+ "3": [" ▀▀█ ", " ▀▀█ ", " ▀▀▀ "],
36
+ "4": [" █ █ ", " ▀▀█ ", " ▀ "],
37
+ "5": [" █▀▀ ", " ▀▀▄ ", " ▀▀▀ "],
38
+ "6": [" █▀▀ ", " █▀▄ ", " ▀▀▀ "],
39
+ "7": [" ▀▀█ ", " █ ", " ▀ "],
40
+ "8": [" █▀█ ", " █▀█ ", " ▀▀▀ "],
41
+ "9": [" █▀█ ", " ▀▀█ ", " ▀▀▀ "],
42
+ " ": [" ", " ", " "],
43
+ "!": [" █ ", " █ ", " ▀ "],
44
+ "?": [" █▀█ ", " ▄▀ ", " ▀ "],
45
+ ".": [" ", " ", " ▀ "],
46
+ ",": [" ", " ", " ▀ "],
47
+ "-": [" ", " ▀▀▀ ", " "],
48
+ "_": [" ", " ", " ▀▀▀ "],
49
+ ":": [" ", " ▀ ", " ▀ "],
50
+ }
51
+
52
+ _TEMPLATES = {
53
+ "block": ["▄▀█", "█▀█", "▀ ▀"], # не используется напрямую, но идея та же
54
+ }
55
+
56
+
57
+ def _render(text):
58
+ text = text.upper()
59
+ lines = ["", "", ""]
60
+ for ch in text:
61
+ glyph = _FONT.get(ch, _FONT["?"])
62
+ w = max(len(g) for g in glyph)
63
+ for i, g in enumerate(glyph):
64
+ lines[i] += g.ljust(w) + " "
65
+ return [l.rstrip() for l in lines]
66
+
67
+
68
+ def big(text, fg=None, gradient=None, align="left", width=None):
69
+ """Крупный заголовок. Если передан gradient=(start_hex, end_hex) —
70
+ каждая строка окрашивается градиентом."""
71
+ lines = _render(text)
72
+ if width:
73
+ from .text import align as _a
74
+ lines = [_a(l, width, align) for l in lines]
75
+ if gradient:
76
+ return "\n".join(gradient_text(l, gradient[0], gradient[1])
77
+ for l in lines)
78
+ if fg:
79
+ return "\n".join(colorize(l, fg=fg) for l in lines)
80
+ return "\n".join(lines)
@@ -0,0 +1,116 @@
1
+ """Рамки, панели, секции, баннеры."""
2
+ from .colors import colorize
3
+ from .text import align, wrap, visible_len
4
+
5
+ STYLES = {
6
+ "single": ("┌", "─", "┐", "│", "└", "┘"),
7
+ "double": ("╔", "═", "╗", "║", "╚", "╝"),
8
+ "round": ("╭", "─", "╮", "│", "╰", "╯"),
9
+ "heavy": ("┏", "━", "┓", "┃", "┗", "┛"),
10
+ "dashed": ("┌", "╌", "┐", "╎", "└", "┘"),
11
+ "dotted": ("┌", "┄", "┐", "┆", "└", "┘"),
12
+ "ascii": ("+", "-", "+", "|", "+", "+"),
13
+ "none": ("", "", "", "", "", ""),
14
+ "shadow": ("┌", "─", "┐", "│", "└", "┘"),
15
+ }
16
+
17
+
18
+ def box(content, title=None, style="round", padding=1, width=None,
19
+ border_fg=None, bg=None, align_mode="left",
20
+ title_align="left", subtitle=None) -> str:
21
+ """Рамка вокруг текста. content — строка или список строк."""
22
+ if isinstance(content, str):
23
+ lines = content.split("\n")
24
+ else:
25
+ lines = [str(x) for x in content]
26
+
27
+ if width:
28
+ content_w = max(width - 2 - padding * 2, 1)
29
+ new_lines = []
30
+ for line in lines:
31
+ new_lines.extend(wrap(line, content_w))
32
+ lines = new_lines
33
+
34
+ inner = max((visible_len(l) for l in lines), default=0)
35
+ if title:
36
+ inner = max(inner, visible_len(title) + 2)
37
+ if subtitle:
38
+ inner = max(inner, visible_len(subtitle) + 2)
39
+ inner += padding * 2
40
+
41
+ tl, h, tr, v, bl, br = STYLES.get(style, STYLES["round"])
42
+ pad = " " * padding
43
+
44
+ if style == "none":
45
+ return "\n".join(pad + align(l, inner - padding * 2, align_mode) + pad
46
+ for l in lines)
47
+
48
+ if title:
49
+ t = f" {title} "
50
+ if title_align == "center":
51
+ side = max((inner - visible_len(t)) // 2, 0)
52
+ top = tl + h * side + t + h * max(inner - side - visible_len(t), 0) + tr
53
+ elif title_align == "right":
54
+ top = tl + h * max(inner - visible_len(t), 0) + t + tr
55
+ else:
56
+ top = tl + h + t + h * max(inner - visible_len(t) - 1, 0) + tr
57
+ else:
58
+ top = tl + h * inner + tr
59
+
60
+ out = [top]
61
+ for line in lines:
62
+ content_aligned = align(line, inner - padding * 2, align_mode)
63
+ out.append(v + pad + content_aligned + pad + v)
64
+
65
+ if subtitle:
66
+ s = f" {subtitle} "
67
+ bot = bl + h * max(inner - visible_len(s), 0) + s + br
68
+ else:
69
+ bot = bl + h * inner + br
70
+ out.append(bot)
71
+
72
+ if border_fg:
73
+ out = [colorize(x, fg=border_fg, bg=bg) for x in out]
74
+ elif bg:
75
+ out = [colorize(x, bg=bg) for x in out]
76
+ return "\n".join(out)
77
+
78
+
79
+ def rule(width=60, char="─", fg=None, title=None, title_align="center",
80
+ bg=None) -> str:
81
+ """Горизонтальный разделитель."""
82
+ if title:
83
+ t = f" {title} "
84
+ tw = visible_len(t)
85
+ if title_align == "left":
86
+ line = char * 2 + t + char * max(width - tw - 2, 0)
87
+ elif title_align == "right":
88
+ line = char * max(width - tw - 2, 0) + t + char * 2
89
+ else:
90
+ side = max((width - tw) // 2, 0)
91
+ line = char * side + t + char * max(width - side - tw, 0)
92
+ else:
93
+ line = char * width
94
+ return colorize(line, fg=fg, bg=bg)
95
+
96
+
97
+ def section(title, width=60, style="round", fg=None, bg=None) -> str:
98
+ """Заголовок секции."""
99
+ return rule(width=width, title=title, fg=fg, bg=bg, title_align="left")
100
+
101
+
102
+ def panel(title, content, style="round", border_fg=None, bg=None,
103
+ width=None, padding=1) -> str:
104
+ """Панель с заголовком."""
105
+ return box(content, title=title, style=style, border_fg=border_fg,
106
+ bg=bg, width=width, padding=padding)
107
+
108
+
109
+ def banner(text, subtitle=None, style="double", fg=None, bg=None,
110
+ width=None) -> str:
111
+ """Крупный центрированный заголовок."""
112
+ lines = [text]
113
+ if subtitle:
114
+ lines.append(subtitle)
115
+ return box(lines, style=style, border_fg=fg, bg=bg, padding=2,
116
+ width=width, align_mode="center")
@@ -0,0 +1,95 @@
1
+ """Простые ASCII-графики."""
2
+ from .colors import colorize
3
+
4
+
5
+ def sparkline(values, chars="▁▂▃▄▅▆▇█", fg=None):
6
+ if not values:
7
+ return ""
8
+ lo, hi = min(values), max(values)
9
+ rng = (hi - lo) or 1
10
+ out = []
11
+ for v in values:
12
+ i = int((v - lo) / rng * (len(chars) - 1))
13
+ ch = chars[i]
14
+ out.append(colorize(ch, fg=fg) if fg else ch)
15
+ return "".join(out)
16
+
17
+
18
+ def bar_chart(data, width=40, char="█", fg=None, show_values=True,
19
+ value_fmt="{:.0f}"):
20
+ items = list(data.items()) if isinstance(data, dict) else list(data)
21
+ if not items:
22
+ return ""
23
+ maxv = max(v for _, v in items) or 1
24
+ max_label = max(len(str(k)) for k, _ in items)
25
+ lines = []
26
+ for label, value in items:
27
+ n = int(value / maxv * width)
28
+ bar = char * n
29
+ if fg:
30
+ bar = colorize(bar, fg=fg)
31
+ lbl = str(label).ljust(max_label)
32
+ suffix = f" {value_fmt.format(value)}" if show_values else ""
33
+ lines.append(f"{lbl} │ {bar}{suffix}")
34
+ return "\n".join(lines)
35
+
36
+
37
+ def gauge(value, total=100, width=30, fg=None, bg=None, label=""):
38
+ ratio = max(0, min(value / (total or 1), 1))
39
+ n = int(ratio * width)
40
+ filled = "█" * n
41
+ empty = "░" * (width - n)
42
+ if fg:
43
+ filled = colorize(filled, fg=fg)
44
+ if bg:
45
+ empty = colorize(empty, fg=bg)
46
+ pre = f"{label} " if label else ""
47
+ return f"{pre}[{filled}{empty}] {ratio * 100:5.1f}%"
48
+
49
+
50
+ def histogram(values, bins=10, width=40, char="█", fg=None,
51
+ show_labels=True):
52
+ if not values:
53
+ return ""
54
+ lo, hi = min(values), max(values)
55
+ if hi == lo:
56
+ hi = lo + 1
57
+ step = (hi - lo) / bins
58
+ counts = [0] * bins
59
+ for v in values:
60
+ i = min(int((v - lo) / step), bins - 1)
61
+ counts[i] += 1
62
+ maxc = max(counts) or 1
63
+ lines = []
64
+ for i, c in enumerate(counts):
65
+ n = int(c / maxc * width)
66
+ bar = char * n
67
+ if fg:
68
+ bar = colorize(bar, fg=fg)
69
+ if show_labels:
70
+ lbl = f"{lo + i * step:>7.2f}"
71
+ lines.append(f"{lbl} │ {bar} {c}")
72
+ else:
73
+ lines.append(bar)
74
+ return "\n".join(lines)
75
+
76
+
77
+ def heatmap(values, rows, cols, char_ramp=" ░▒▓█", fg=None):
78
+ """Простая heatmap из 2D-списка (по строкам)."""
79
+ if not values:
80
+ return ""
81
+ flat = [v for row in values for v in row]
82
+ lo, hi = min(flat), max(flat)
83
+ rng = (hi - lo) or 1
84
+ n = len(char_ramp) - 1
85
+ out = []
86
+ for r in range(rows):
87
+ row = values[r] if r < len(values) else []
88
+ line = []
89
+ for c in range(cols):
90
+ v = row[c] if c < len(row) else lo
91
+ i = int((v - lo) / rng * n)
92
+ ch = char_ramp[i]
93
+ line.append(colorize(ch, fg=fg) if fg else ch)
94
+ out.append("".join(line))
95
+ return "\n".join(out)