ttyengine 0.1.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.
@@ -0,0 +1,473 @@
1
+ import re, shutil, colorsys
2
+ from functools import lru_cache
3
+ from . import ansi
4
+ from . import region as _regions
5
+ from .figlet import Figlet, available
6
+ from .image_reader import read as readimage
7
+
8
+ DEFAULT_IMAGE_WIDTH = 80
9
+
10
+ RGB_MAP = {
11
+ "red": (205, 49, 49),
12
+ "orange": (255, 165, 0),
13
+ "brown": (139, 69, 19),
14
+ "gold": (255, 215, 0),
15
+ "yellow": (229, 229, 16),
16
+ "green": (13, 188, 121),
17
+ "turquoise": (64, 224, 208),
18
+ "cyan": (17, 168, 205),
19
+ "blue": (36, 114, 200),
20
+ "purple": (128, 0, 128),
21
+ "violet": (238, 130, 238),
22
+ "magenta": (188, 63, 188),
23
+ "pink": (255, 105, 180), # 13 colors
24
+ "brightred": (241, 76, 76),
25
+ "brightorange": (255, 200, 0),
26
+ "brightbrown": (205, 133, 63),
27
+ "brightgold": (255, 235, 100),
28
+ "brightyellow": (245, 245, 67),
29
+ "brightgreen": (35, 209, 139),
30
+ "brightturquoise": (127, 255, 212),
31
+ "brightcyan": (41, 184, 219),
32
+ "brightblue": (59, 142, 234),
33
+ "brightpurple": (191, 0, 191),
34
+ "brightviolet": (255, 170, 255),
35
+ "brightmagenta": (214, 112, 214),
36
+ "brightpink": (255, 182, 193), # 13 bright colors
37
+ "darkred": (139, 0, 0),
38
+ "darkorange": (255, 140, 0),
39
+ "darkbrown": (60, 30, 10),
40
+ "darkgold": (184, 134, 11),
41
+ "darkyellow": (139, 139, 0),
42
+ "darkgreen": (0, 100, 0),
43
+ "darkturquoise": (0, 134, 139),
44
+ "darkcyan": (0, 139, 139),
45
+ "darkblue": (0, 0, 139),
46
+ "darkpurple": (75, 0, 130),
47
+ "darkviolet": (148, 0, 211),
48
+ "darkmagenta": (139, 0, 139),
49
+ "darkpink": (231, 84, 128), # 13 dark colors
50
+ "black": (0, 0, 0),
51
+ "darkgray": (64, 64, 64),
52
+ "brightblack": (102, 102, 102),
53
+ "gray": (128, 128, 128),
54
+ "brightgray": (192, 192, 192),
55
+ "default": (204, 204, 204),
56
+ "bright": (229, 229, 229),
57
+ "white": (255, 255, 255), # 8 gray scale colors
58
+ }
59
+
60
+ FONT_MAP = {
61
+ 1: "standard", 2: "big", 3: "slant", 4: "doom", 5: "bubble",
62
+ }
63
+
64
+ DEFAULT_BG = (0,0,0)
65
+ DEFAULT_FG = RGB_MAP["default"]
66
+ bgrecomp = ansi.Pattern(re.escape(ansi.CSI) + r'48;2;(\d{1,3});(\d{1,3});(\d{1,3})m')
67
+ fgrecomp = ansi.Pattern(re.escape(ansi.CSI) + r'38;2;(\d{1,3});(\d{1,3});(\d{1,3})m')
68
+ recomp = ansi.SGR
69
+ ALPHA_FG = 1000
70
+ ALPHA_FGBACK = 1300
71
+ ALPHA_BG = 1600
72
+ alphamark = ansi.Pattern(re.escape(ansi.CSI) + r'(1[0-8]\d\d)m')
73
+ _figlet_cache = {}
74
+ DEFAULT_SHADES = "█"#" .:-=+*#%@"
75
+
76
+
77
+ def alphacode(base, alpha):
78
+ return ansi.sgr(base + int(round(max(0.0, min(1.0, alpha)) * 255)))
79
+
80
+
81
+ def alphaparse(text):
82
+ fa = ba = None
83
+ back = False
84
+ for match in alphamark.finditer(text):
85
+ code = int(match.group(1))
86
+ if ALPHA_BG <= code <= ALPHA_BG + 255:
87
+ ba = (code - ALPHA_BG) / 255
88
+ elif ALPHA_FGBACK <= code <= ALPHA_FGBACK + 255:
89
+ fa = (code - ALPHA_FGBACK) / 255
90
+ back = True
91
+ elif ALPHA_FG <= code <= ALPHA_FG + 255:
92
+ fa = (code - ALPHA_FG) / 255
93
+ back = False
94
+ return fa, back, ba
95
+
96
+
97
+ @lru_cache(maxsize=512)
98
+ def _rgb(color):
99
+ return Styler._rgb_uncached(color)
100
+
101
+
102
+ class Styler:
103
+ def __init__(self, source=None, bgcolor=DEFAULT_BG, fgcolor=DEFAULT_FG):
104
+ self.source = source
105
+ self._bgcolor = bgcolor
106
+ self._fgcolor = fgcolor
107
+ self.shapes = Shapes
108
+
109
+ @property
110
+ def bgcolor(self):
111
+ return self._bgcolor if self.source is None else self.source.bgcolor
112
+ @bgcolor.setter
113
+ def bgcolor(self, value):
114
+ if self.source is None: self._bgcolor = value
115
+ else: self.source.bgcolor = value
116
+
117
+ @property
118
+ def fgcolor(self):
119
+ return self._fgcolor if self.source is None else self.source.fgcolor
120
+ @fgcolor.setter
121
+ def fgcolor(self, value):
122
+ if self.source is None: self._fgcolor = value
123
+ else: self.source.fgcolor = value
124
+
125
+ @staticmethod
126
+ def getfiglet(font, width):
127
+ key = (font, width)
128
+ if key not in _figlet_cache:
129
+ _figlet_cache[key] = Figlet(font=font, width=width)
130
+ return _figlet_cache[key]
131
+
132
+ @staticmethod
133
+ def fonts():
134
+ return available()
135
+
136
+ @staticmethod
137
+ def rgb(color):
138
+ if isinstance(color, list):
139
+ color = tuple(color)
140
+ try:
141
+ hash(color)
142
+ return _rgb(color)
143
+ except (TypeError, ValueError, OverflowError):
144
+ return None
145
+
146
+ @staticmethod
147
+ def _rgb_uncached(color):
148
+ if isinstance(color, tuple):
149
+ if len(color) == 3:
150
+ return tuple(max(0, min(255, int(c))) for c in color)
151
+ elif len(color) == 2:
152
+ rainbow = max(1, min(3, color[1]))
153
+ start = (max(1, min(4, color[1]))-1) * 13
154
+ rainbow *= 13
155
+ if color[1] > 3: rainbow += 8
156
+ for i,c in enumerate(RGB_MAP.values()):
157
+ if i in range(start, rainbow) and i==color[0]:
158
+ return c
159
+ elif len(color) == 1:
160
+ for i,c in enumerate(RGB_MAP.values()):
161
+ if i==color[0]:
162
+ return c
163
+ elif isinstance(color, (float,int)):
164
+ for i,c in enumerate(RGB_MAP.values()):
165
+ if i==round(color):
166
+ return c
167
+ elif isinstance(color, str):
168
+ return RGB_MAP.get(color.lower(), None)
169
+ return None
170
+
171
+ @staticmethod
172
+ def hsv(hue, saturation=1.0, value=1.0):
173
+ s = max(0.0, min(1.0, saturation))
174
+ v = max(0.0, min(1.0, value))
175
+ r, g, b = colorsys.hsv_to_rgb((hue % 360) / 360, s, v)
176
+ return (round(r * 255), round(g * 255), round(b * 255))
177
+
178
+ @staticmethod
179
+ def getcolor(text:str, back=False, default=None):
180
+ fallback = default if default is not None else (DEFAULT_BG if back else DEFAULT_FG)
181
+ target = ansi.CSI + ("48;2;" if back else "38;2;")
182
+ if target not in text:
183
+ return fallback, ""
184
+ matches = list(bgrecomp.finditer(text) if back else fgrecomp.finditer(text))
185
+ tupl = tuple(int(x) for x in matches[-1].groups()) if matches else fallback
186
+ color = matches[-1].group(0) if matches else ""
187
+ return tupl, color
188
+
189
+ def _style(self, text: str, color=None, alpha=None, font=None, width=80, bgcolor=None,
190
+ bgalpha=None, bg=DEFAULT_BG, fg=DEFAULT_FG, onbg=False, reset=True, clean=False,
191
+ bold=False, italic=False, underline=False, size=None):
192
+ rgb = self.rgb
193
+ getcolor = self.getcolor
194
+ if alpha is not None:
195
+ alpha = max(0.0, min(1.0, alpha))
196
+ if bgalpha is not None:
197
+ bgalpha = max(0.0, min(1.0, bgalpha))
198
+ bg_rgb = rgb(bg) or bg
199
+ fg_rgb = rgb(fg) or fg
200
+ match_bgcolor = ""
201
+ match_color = ""
202
+ user_color = color
203
+ user_bgcolor = bgcolor
204
+ bgcolor_rgb = rgb(bgcolor)
205
+ color_rgb = rgb(color)
206
+ old_fa, old_back, old_ba = alphaparse(text) if clean else (None, False, None)
207
+ fg_mark = ""
208
+ bg_mark = ""
209
+ if user_bgcolor is not None or bgalpha is not None:
210
+ _bgalpha = bgalpha if bgalpha is not None else 1.0
211
+ bg_marked = _bgalpha
212
+ transparent_bg = False
213
+ if bgcolor_rgb is None:
214
+ bgcolor_rgb, match_bgcolor = getcolor(text, True, bg_rgb)
215
+ transparent_bg = not match_bgcolor
216
+ if old_ba is not None:
217
+ bg_marked = _bgalpha * old_ba
218
+ if transparent_bg:
219
+ ansi_bgcolor = ""
220
+ else:
221
+ if bg_marked < 1:
222
+ bg_mark = alphacode(ALPHA_BG, bg_marked)
223
+ rb = int(bg_rgb[0] * (1 - _bgalpha) + bgcolor_rgb[0] * _bgalpha)
224
+ gb = int(bg_rgb[1] * (1 - _bgalpha) + bgcolor_rgb[1] * _bgalpha)
225
+ bb = int(bg_rgb[2] * (1 - _bgalpha) + bgcolor_rgb[2] * _bgalpha)
226
+ ansi_bgcolor = ansi.bg(rb, gb, bb)
227
+ else:
228
+ bgcolor_rgb, match_bgcolor = getcolor(text, True, bg_rgb)
229
+ ansi_bgcolor = ""
230
+ if match_bgcolor and old_ba is not None and old_ba < 1:
231
+ bg_mark = alphacode(ALPHA_BG, old_ba)
232
+ if user_color is not None or alpha is not None:
233
+ _alpha = alpha if alpha is not None else 1.0
234
+ fg_marked = _alpha
235
+ fg_back = bool(onbg)
236
+ if color_rgb is None:
237
+ color_rgb, match_color = getcolor(text, False, fg_rgb)
238
+ if old_fa is not None:
239
+ fg_marked = _alpha * old_fa
240
+ if alpha is None:
241
+ fg_back = old_back
242
+ if fg_marked < 1:
243
+ fg_mark = alphacode(ALPHA_FGBACK if fg_back else ALPHA_FG, fg_marked)
244
+ activebg_rgb = bg_rgb if onbg else bgcolor_rgb
245
+ r = int(activebg_rgb[0] * (1 - _alpha) + color_rgb[0] * _alpha)
246
+ g = int(activebg_rgb[1] * (1 - _alpha) + color_rgb[1] * _alpha)
247
+ b = int(activebg_rgb[2] * (1 - _alpha) + color_rgb[2] * _alpha)
248
+ ansi_color = ansi.fg(r, g, b)
249
+ else:
250
+ ansi_color = ""
251
+ if old_fa is not None:
252
+ fg_mark = alphacode(ALPHA_FGBACK if old_back else ALPHA_FG, old_fa)
253
+ if clean:
254
+ bold = bold or ansi.BOLD in text
255
+ italic = italic or ansi.ITALIC in text
256
+ underline = underline or ansi.UNDERLINE in text
257
+ ascii_art = recomp.sub('', text)
258
+ if not ansi_bgcolor:
259
+ ansi_bgcolor = match_bgcolor
260
+ if not ansi_color:
261
+ ansi_color = match_color
262
+ else:
263
+ ascii_art = text
264
+ if font is None:
265
+ font = size
266
+ if font is not None and (not isinstance(font, int) or font >= 1):
267
+ name = FONT_MAP[max(1, min(5, font))] if isinstance(font, int) else font
268
+ ascii_art = self.getfiglet(font=name, width=width).renderText(ascii_art)
269
+ ansi_attrs = (ansi.BOLD if bold else '') + (ansi.ITALIC if italic else '') + (ansi.UNDERLINE if underline else '')
270
+ prefix = ansi_bgcolor + ansi_color + ansi_attrs + bg_mark + fg_mark
271
+ suffix = ansi.RESET if reset else ""
272
+ trailing = '\n' if ascii_art.endswith('\n') else ''
273
+
274
+ lines = ascii_art.split('\n')
275
+ while lines and not lines[-1].strip():
276
+ lines.pop()
277
+
278
+ styled_text = '\n'.join(prefix + line.rstrip() + suffix for line in lines) + trailing
279
+ return styled_text
280
+
281
+ def style(self, *args, **kwargs):
282
+ if len(args) <= 7 and 'bg' not in kwargs:
283
+ kwargs['bg'] = self.bgcolor
284
+ if len(args) <= 8 and 'fg' not in kwargs:
285
+ kwargs['fg'] = self.fgcolor
286
+ return self._style(*args, **kwargs)
287
+
288
+ @staticmethod
289
+ def fill(color=None, width=None, height=None, xoffset=0, yoffset=0, termx=None, termy=None, region=None):
290
+ if termx is None or termy is None:
291
+ termx, termy = shutil.get_terminal_size()
292
+ if region is not None:
293
+ xoffset, yoffset, width, height = _regions.resolve(region, termx, termy)
294
+ width = termx if width is None else int(width)
295
+ height = termy if height is None else int(height)
296
+ xoffset = int(xoffset)
297
+ yoffset = int(yoffset)
298
+ x_start = max(0, xoffset)
299
+ y_start = max(0, yoffset)
300
+ x_end = min(termx, xoffset + width)
301
+ y_end = min(termy, yoffset + height)
302
+ if x_end <= x_start or y_end <= y_start:
303
+ return ""
304
+ draw_width = x_end - x_start
305
+ if color is None:
306
+ row_str = ansi.RESET + " " * draw_width
307
+ return "".join(
308
+ ansi.move(row + 1, x_start + 1) + row_str
309
+ for row in range(y_start, y_end)
310
+ )
311
+ rgb = Styler.rgb(color)
312
+ if rgb is None:
313
+ raise ValueError('unknown color: %r' % (color,))
314
+ r, g, b = rgb
315
+ row_str = ansi.bg(r, g, b) + " " * draw_width + ansi.RESET
316
+ return "".join(
317
+ ansi.move(row + 1, x_start + 1) + row_str
318
+ for row in range(y_start, y_end)
319
+ )
320
+
321
+ @staticmethod
322
+ def image(path, width=None, height=None, shades=DEFAULT_SHADES, aspect=0.5, color=None):
323
+ if not shades:
324
+ raise ValueError("shades must not be empty")
325
+ if aspect <= 0:
326
+ raise ValueError("aspect must be positive")
327
+ if color is None:
328
+ color = len(shades) == 1
329
+ iw, ih, px = readimage(path)
330
+ if width is None and height is None:
331
+ width = min(iw, DEFAULT_IMAGE_WIDTH)
332
+ if width is None:
333
+ width = round(height * iw / (ih * aspect))
334
+ if height is None:
335
+ height = round(width * ih / iw * aspect)
336
+ width = max(1, int(width))
337
+ height = max(1, int(height))
338
+ top = len(shades) - 1
339
+ lines = []
340
+ for cy in range(height):
341
+ y0 = cy * ih // height
342
+ y1 = max(y0 + 1, (cy + 1) * ih // height)
343
+ sr = [0] * iw
344
+ sg = [0] * iw
345
+ sb = [0] * iw
346
+ sa = [0] * iw
347
+ for y in range(y0, y1):
348
+ o = y * iw * 4
349
+ r = px[o:o + iw * 4:4]
350
+ g = px[o + 1:o + iw * 4:4]
351
+ b = px[o + 2:o + iw * 4:4]
352
+ a = px[o + 3:o + iw * 4:4]
353
+ sr = [t + v * w for t, v, w in zip(sr, r, a)]
354
+ sg = [t + v * w for t, v, w in zip(sg, g, a)]
355
+ sb = [t + v * w for t, v, w in zip(sb, b, a)]
356
+ if color:
357
+ sa = [t + w for t, w in zip(sa, a)]
358
+ row = []
359
+ current = None
360
+ for cx in range(width):
361
+ x0 = cx * iw // width
362
+ x1 = max(x0 + 1, (cx + 1) * iw // width)
363
+ norm = (x1 - x0) * (y1 - y0) * 255
364
+ red = sum(sr[x0:x1]) / norm
365
+ green = sum(sg[x0:x1]) / norm
366
+ blue = sum(sb[x0:x1]) / norm
367
+ level = (54 * red + 183 * green + 19 * blue) / 65280
368
+ char = shades[min(top, int(level * len(shades)))]
369
+ if color:
370
+ if not sum(sa[x0:x1]):
371
+ char = ' '
372
+ elif char != ' ':
373
+ rgb = (round(red), round(green), round(blue))
374
+ if rgb != current:
375
+ row.append(ansi.fg(*rgb))
376
+ current = rgb
377
+ row.append(char)
378
+ line = ''.join(row).rstrip(' ')
379
+ lines.append(line + ansi.RESET if current else line)
380
+ return '\n'.join(lines)
381
+
382
+ class Shapes:
383
+ @staticmethod
384
+ def ellipse(rx, ry, char='o', filled=False, thickness=1):
385
+ rx = round(rx)
386
+ ry = round(ry)
387
+ if rx < 1 or ry < 1:
388
+ return char
389
+ thickness = max(1, thickness)
390
+ ox, oy = rx + 0.5, ry + 0.5
391
+ ix, iy = ox - thickness, oy - thickness
392
+ rows = []
393
+ for dy in range(-ry, ry + 1):
394
+ row = ''
395
+ for dx in range(-rx, rx + 1):
396
+ hit = (dx / ox) ** 2 + (dy / oy) ** 2 <= 1
397
+ if hit and not filled and ix > 0 and iy > 0:
398
+ hit = (dx / ix) ** 2 + (dy / iy) ** 2 >= 1
399
+ row += char if hit else ' '
400
+ rows.append(row)
401
+ return '\n'.join(rows)
402
+
403
+ @staticmethod
404
+ def circle(radius, char='o', filled=False, thickness=1, aspect=0.5):
405
+ return Shapes.ellipse(radius * (1 / aspect), radius, char, filled, thickness)
406
+
407
+ @staticmethod
408
+ def block(width, height=None, char='█', filled=False, thickness=1, aspect=0.5):
409
+ width = max(1, round(width))
410
+ height = max(1, round(width * aspect) if height is None else round(height))
411
+ thickness = max(1, round(thickness))
412
+ rows = []
413
+ for i in range(height):
414
+ if filled or i < thickness or i >= height - thickness or width <= 2 * thickness:
415
+ rows.append(char * width)
416
+ else:
417
+ rows.append(char * thickness + ' ' * (width - 2 * thickness) + char * thickness)
418
+ return '\n'.join(rows)
419
+
420
+ @staticmethod
421
+ def triangle(height, base=None, char='█', filled=False, thickness=1, aspect=0.5, direction='up'):
422
+ height = max(1, round(height))
423
+ thickness = max(1, round(thickness))
424
+ if base is None:
425
+ base = height / aspect
426
+ half = max(0, round((base - 1) / 2))
427
+ widths = [2 * round(half * i / (height - 1) if height > 1 else 0) + 1 for i in range(height)]
428
+ full = widths[-1]
429
+ rows = []
430
+ for i, w in enumerate(widths):
431
+ if filled or i >= height - thickness or w <= 2 * thickness:
432
+ body = char * w
433
+ else:
434
+ body = char * thickness + ' ' * (w - 2 * thickness) + char * thickness
435
+ rows.append(' ' * ((full - w) // 2) + body)
436
+ if direction == 'down':
437
+ rows.reverse()
438
+ return '\n'.join(rows)
439
+
440
+ @staticmethod
441
+ def diamond(radius, char='█', filled=False, thickness=1, aspect=0.5):
442
+ ry = max(1, round(radius))
443
+ rx = max(1, round(radius * (1 / aspect)))
444
+ thickness = max(1, round(thickness))
445
+ rows = []
446
+ for dy in range(-ry, ry + 1):
447
+ hw = round(rx * (1 - abs(dy) / ry))
448
+ w = 2 * hw + 1
449
+ if filled or w <= 2 * thickness:
450
+ body = char * w
451
+ else:
452
+ body = char * thickness + ' ' * (w - 2 * thickness) + char * thickness
453
+ rows.append(' ' * (rx - hw) + body)
454
+ return '\n'.join(rows)
455
+
456
+ @staticmethod
457
+ def line(length, char='█', thickness=1, vertical=False):
458
+ length = max(1, round(length))
459
+ thickness = max(1, round(thickness))
460
+ if vertical:
461
+ return '\n'.join([char * thickness] * length)
462
+ return '\n'.join([char * length] * thickness)
463
+
464
+
465
+ _default_styler = Styler()
466
+ rgb = Styler.rgb
467
+ hsv = Styler.hsv
468
+ getcolor = Styler.getcolor
469
+ getfiglet = Styler.getfiglet
470
+ fonts = Styler.fonts
471
+ fill = Styler.fill
472
+ image = Styler.image
473
+ style = _default_styler._style