cuifw 1.0.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.
cuifw/__init__.py ADDED
@@ -0,0 +1,2012 @@
1
+ """
2
+ cuifw - The best Console Library of the Python
3
+
4
+ A lightweight library for terminal-based applications with support for:
5
+ - ANSI colors and styling
6
+ - 2D/3D rendering in terminal
7
+ - Keyboard input handling
8
+ - Process management
9
+ - Simple networking (sockets)
10
+ - Built-in web server
11
+ - Console animations
12
+
13
+ Author: Suleiman
14
+ License: MIT
15
+ Copyright © Suleiman 2026
16
+ """
17
+ import os
18
+ import sys
19
+ import time
20
+ import math
21
+ import random
22
+ import socket
23
+ import subprocess as CMD
24
+ import threading
25
+ import webbrowser
26
+ from http.server import HTTPServer, BaseHTTPRequestHandler
27
+ import urllib.request
28
+ import urllib.parse
29
+
30
+ class Colors:
31
+ RESET = "\033[0m"
32
+ RED = "\033[31m"
33
+ GREEN = "\033[32m"
34
+ BLUE = "\033[34m"
35
+ CYAN = "\033[36m"
36
+ MAGENTA = "\033[35m"
37
+ YELLOW = "\033[33m"
38
+ WHITE = "\033[37m"
39
+ BLACK = "\033[30m"
40
+ BG_RED = "\033[41m"
41
+ BG_GREEN = "\033[42m"
42
+ BG_BLUE = "\033[44m"
43
+ BG_CYAN = "\033[46m"
44
+ BG_MAGENTA = "\033[45m"
45
+ BG_YELLOW = "\033[43m"
46
+ BG_WHITE = "\033[47m"
47
+ BG_BLACK = "\033[40m"
48
+ BOLD = "\033[1m"
49
+ DIM = "\033[2m"
50
+ ITALIC = "\033[3m"
51
+ UNDERLINE = "\033[4m"
52
+ BLINK = "\033[5m"
53
+ REVERSE = "\033[7m"
54
+ HIDDEN = "\033[8m"
55
+ STRIKE = "\033[9m"
56
+
57
+ @staticmethod
58
+ def rgb(r, g, b):
59
+ return f"\033[38;2;{r};{g};{b}m"
60
+
61
+ @staticmethod
62
+ def bg_rgb(r, g, b):
63
+ return f"\033[48;2;{r};{g};{b}m"
64
+
65
+ @staticmethod
66
+ def colorize(text, color, reset=True):
67
+ reset_code = Colors.RESET if reset else ""
68
+ return f"{color}{text}{reset_code}"
69
+
70
+ class Render:
71
+ @staticmethod
72
+ def DrawPic(points=None, width=80, height=25, clear_before_draw=False, default_char=' '):
73
+ if clear_before_draw:
74
+ os.system("cls" if os.name == "nt" else "clear")
75
+ field = [[default_char for _ in range(width)] for _ in range(height)]
76
+ if points:
77
+ for item in points:
78
+ if len(item) >= 3:
79
+ x, y, char = item[0], item[1], item[2]
80
+ if 0 <= x < width and 0 <= y < height:
81
+ if len(item) == 4:
82
+ color = item[3]
83
+ field[y][x] = f"{color}{char}{Colors.RESET}"
84
+ elif len(item) == 5:
85
+ color, bg = item[3], item[4]
86
+ field[y][x] = f"{color}{bg}{char}{Colors.RESET}"
87
+ else:
88
+ field[y][x] = char
89
+ for row in field:
90
+ print(''.join(row))
91
+ @staticmethod
92
+ def _apply_color(points, color=None, bg=None):
93
+ if color is None and bg is None:
94
+ return points
95
+ result = []
96
+ for p in points:
97
+ if len(p) == 3:
98
+ x, y, char = p
99
+ if color and bg:
100
+ result.append((x, y, char, color, bg))
101
+ elif color:
102
+ result.append((x, y, char, color))
103
+ else:
104
+ result.append((x, y, char, Colors.BG_BLACK + bg))
105
+ else:
106
+ result.append(p)
107
+ return result
108
+
109
+ @staticmethod
110
+ def get_line_points(x1, y1, x2, y2, char='#', color=None, bg=None):
111
+ points = []
112
+ dx = abs(x2 - x1)
113
+ dy = abs(y2 - y1)
114
+ sx = 1 if x1 < x2 else -1
115
+ sy = 1 if y1 < y2 else -1
116
+ err = dx - dy
117
+ while True:
118
+ points.append((x1, y1, char))
119
+ if x1 == x2 and y1 == y2:
120
+ break
121
+ e2 = err * 2
122
+ if e2 > -dy:
123
+ err -= dy
124
+ x1 += sx
125
+ if e2 < dx:
126
+ err += dx
127
+ y1 += sy
128
+ return Render._apply_color(points, color, bg)
129
+
130
+ @staticmethod
131
+ def get_circle_points(cx, cy, r, char='#', color=None, bg=None):
132
+ points = []
133
+ for angle in range(0, 360, 5):
134
+ rad = angle * math.pi / 180
135
+ x = int(cx + r * math.cos(rad))
136
+ y = int(cy + r * math.sin(rad))
137
+ points.append((x, y, char))
138
+ return Render._apply_color(points, color, bg)
139
+
140
+ @staticmethod
141
+ def get_filled_circle_points(cx, cy, r, char='#', color=None, bg=None):
142
+ points = []
143
+ for y in range(cy - r, cy + r + 1):
144
+ for x in range(cx - r, cx + r + 1):
145
+ if (x - cx) ** 2 + (y - cy) ** 2 <= r ** 2:
146
+ points.append((x, y, char))
147
+ return Render._apply_color(points, color, bg)
148
+
149
+ @staticmethod
150
+ def get_rect_points(x1, y1, x2, y2, char='#', color=None, bg=None):
151
+ points = []
152
+ for x in range(x1, x2 + 1):
153
+ points.append((x, y1, char))
154
+ points.append((x, y2, char))
155
+ for y in range(y1, y2 + 1):
156
+ points.append((x1, y, char))
157
+ points.append((x2, y, char))
158
+ return Render._apply_color(points, color, bg)
159
+
160
+ @staticmethod
161
+ def get_fill_rect_points(x1, y1, x2, y2, char='#', color=None, bg=None):
162
+ points = []
163
+ for y in range(y1, y2 + 1):
164
+ for x in range(x1, x2 + 1):
165
+ points.append((x, y, char))
166
+ return Render._apply_color(points, color, bg)
167
+
168
+ @staticmethod
169
+ def get_text_points(x, y, text, char=None, color=None, bg=None):
170
+ points = []
171
+ if char is None:
172
+ for i, ch in enumerate(text):
173
+ points.append((x + i, y, ch))
174
+ else:
175
+ for i in range(len(text)):
176
+ points.append((x + i, y, char))
177
+ return Render._apply_color(points, color, bg)
178
+
179
+ @staticmethod
180
+ def get_triangle_points(x1, y1, x2, y2, x3, y3, char='#', color=None, bg=None):
181
+ points = []
182
+ points.extend(Render.get_line_points(x1, y1, x2, y2, char))
183
+ points.extend(Render.get_line_points(x2, y2, x3, y3, char))
184
+ points.extend(Render.get_line_points(x3, y3, x1, y1, char))
185
+ return Render._apply_color(points, color, bg)
186
+
187
+ @staticmethod
188
+ def get_filled_triangle_points(x1, y1, x2, y2, x3, y3, char='#', color=None, bg=None):
189
+ points = []
190
+ min_x = min(x1, x2, x3)
191
+ max_x = max(x1, x2, x3)
192
+ min_y = min(y1, y2, y3)
193
+ max_y = max(y1, y2, y3)
194
+ for y in range(min_y, max_y + 1):
195
+ for x in range(min_x, max_x + 1):
196
+ if Render._point_in_triangle(x, y, x1, y1, x2, y2, x3, y3):
197
+ points.append((x, y, char))
198
+ return Render._apply_color(points, color, bg)
199
+
200
+ @staticmethod
201
+ def get_heart_points(cx, cy, size=5, char='#', color=None, bg=None):
202
+ points = []
203
+ for y in range(-size, size + 1):
204
+ for x in range(-size, size + 1):
205
+ if ((x * x + y * y - size * size) ** 3 <= size * size * x * x * y * y * 0.5):
206
+ points.append((cx + x, cy - y, char))
207
+ return Render._apply_color(points, color, bg)
208
+
209
+ @staticmethod
210
+ def get_star_points(cx, cy, radius=5, points_count=5, char='*', color=None, bg=None):
211
+ points = []
212
+ angle = -math.pi / 2
213
+ step = math.pi / points_count
214
+ outer_points = []
215
+ inner_points = []
216
+ for i in range(points_count * 2):
217
+ r = radius if i % 2 == 0 else radius * 0.4
218
+ x = cx + r * math.cos(angle + i * step)
219
+ y = cy + r * math.sin(angle + i * step)
220
+ if i % 2 == 0:
221
+ outer_points.append((x, y))
222
+ else:
223
+ inner_points.append((x, y))
224
+ for i in range(points_count):
225
+ p1 = outer_points[i]
226
+ p2 = inner_points[i]
227
+ p3 = outer_points[(i + 1) % points_count]
228
+ points.extend(Render.get_line_points(int(p1[0]), int(p1[1]), int(p2[0]), int(p2[1]), char))
229
+ points.extend(Render.get_line_points(int(p2[0]), int(p2[1]), int(p3[0]), int(p3[1]), char))
230
+ return Render._apply_color(points, color, bg)
231
+
232
+ @staticmethod
233
+ def get_bezier_points(x1, y1, x2, y2, x3, y3, steps=20, char='#', color=None, bg=None):
234
+ points = []
235
+ for t in range(steps + 1):
236
+ t = t / steps
237
+ x = (1 - t) ** 2 * x1 + 2 * (1 - t) * t * x2 + t ** 2 * x3
238
+ y = (1 - t) ** 2 * y1 + 2 * (1 - t) * t * y2 + t ** 2 * y3
239
+ points.append((int(x), int(y), char))
240
+ return Render._apply_color(points, color, bg)
241
+
242
+ @staticmethod
243
+ def get_wave_points(cx, cy, amplitude=5, length=20, char='~', color=None, bg=None):
244
+ points = []
245
+ for x in range(length):
246
+ y = cy + int(amplitude * math.sin(x * 0.5))
247
+ points.append((cx + x, y, char))
248
+ return Render._apply_color(points, color, bg)
249
+
250
+ @staticmethod
251
+ def get_spiral_points(cx, cy, turns=3, radius=10, step=0.1, char='*', color=None, bg=None):
252
+ points = []
253
+ angle = 0
254
+ r = 0
255
+ while r < radius:
256
+ x = int(cx + r * math.cos(angle))
257
+ y = int(cy + r * math.sin(angle))
258
+ points.append((x, y, char))
259
+ angle += step
260
+ r += step * 2
261
+ return Render._apply_color(points, color, bg)
262
+
263
+ @staticmethod
264
+ def get_grid_points(x1, y1, x2, y2, step=2, char='+', color=None, bg=None):
265
+ points = []
266
+ for x in range(x1, x2 + 1, step):
267
+ for y in range(y1, y2 + 1, step):
268
+ points.append((x, y, char))
269
+ return Render._apply_color(points, color, bg)
270
+
271
+ @staticmethod
272
+ def get_noise_points(cx, cy, radius, count=50, char='.', color=None, bg=None):
273
+ points = []
274
+ for _ in range(count):
275
+ angle = random.uniform(0, 2 * math.pi)
276
+ r = random.uniform(0, radius)
277
+ x = int(cx + r * math.cos(angle))
278
+ y = int(cy + r * math.sin(angle))
279
+ points.append((x, y, char))
280
+ return Render._apply_color(points, color, bg)
281
+
282
+ # ---- 3D ----
283
+ @staticmethod
284
+ def _point_in_triangle(px, py, x1, y1, x2, y2, x3, y3):
285
+ def sign(a, b, c):
286
+ return (a[0] - c[0]) * (b[1] - c[1]) - (b[0] - c[0]) * (a[1] - c[1])
287
+ p = (px, py)
288
+ d1 = sign(p, (x2, y2), (x1, y1))
289
+ d2 = sign(p, (x3, y3), (x2, y2))
290
+ d3 = sign(p, (x1, y1), (x3, y3))
291
+ has_neg = (d1 < 0) or (d2 < 0) or (d3 < 0)
292
+ has_pos = (d1 > 0) or (d2 > 0) or (d3 > 0)
293
+ return not (has_neg and has_pos)
294
+
295
+ @staticmethod
296
+ def _rotx(x, y, z, angle):
297
+ rad = math.radians(angle)
298
+ cos_a = math.cos(rad)
299
+ sin_a = math.sin(rad)
300
+ y_new = y * cos_a - z * sin_a
301
+ z_new = y * sin_a + z * cos_a
302
+ return x, y_new, z_new
303
+
304
+ @staticmethod
305
+ def _roty(x, y, z, angle):
306
+ rad = math.radians(angle)
307
+ cos_a = math.cos(rad)
308
+ sin_a = math.sin(rad)
309
+ x_new = x * cos_a + z * sin_a
310
+ z_new = -x * sin_a + z * cos_a
311
+ return x_new, y, z_new
312
+
313
+ @staticmethod
314
+ def _rotz(x, y, z, angle):
315
+ rad = math.radians(angle)
316
+ cos_a = math.cos(rad)
317
+ sin_a = math.sin(rad)
318
+ x_new = x * cos_a - y * sin_a
319
+ y_new = x * sin_a + y * cos_a
320
+ return x_new, y_new, z
321
+
322
+ @staticmethod
323
+ def _project_3d(x, y, z, rotx, roty, rotz, scale, cx, cy):
324
+ x, y, z = Render._rotx(x, y, z, rotx)
325
+ x, y, z = Render._roty(x, y, z, roty)
326
+ x, y, z = Render._rotz(x, y, z, rotz)
327
+ x2d = int(x * scale + cx)
328
+ y2d = int(-y * scale + cy)
329
+ return x2d, y2d
330
+
331
+ @staticmethod
332
+ def get_cube_3d_points(size=5, rotx=0, roty=0, rotz=0, scale=10, cx=40, cy=12, char='#', color=None, bg=None):
333
+ s = size / 2
334
+ vertices = [
335
+ (-s, -s, -s), (s, -s, -s), (s, s, -s), (-s, s, -s),
336
+ (-s, -s, s), (s, -s, s), (s, s, s), (-s, s, s)
337
+ ]
338
+ edges = [
339
+ (0,1), (1,2), (2,3), (3,0),
340
+ (4,5), (5,6), (6,7), (7,4),
341
+ (0,4), (1,5), (2,6), (3,7)
342
+ ]
343
+ points = []
344
+ projected = []
345
+ for v in vertices:
346
+ x2d, y2d = Render._project_3d(v[0], v[1], v[2], rotx, roty, rotz, scale, cx, cy)
347
+ projected.append((x2d, y2d))
348
+ for edge in edges:
349
+ x1, y1 = projected[edge[0]]
350
+ x2, y2 = projected[edge[1]]
351
+ points.extend(Render.get_line_points(x1, y1, x2, y2, char))
352
+ return Render._apply_color(points, color, bg)
353
+
354
+ @staticmethod
355
+ def get_pyramid_3d_points(size=5, rotx=0, roty=0, rotz=0, scale=10, cx=40, cy=12, char='#', color=None, bg=None):
356
+ s = size / 2
357
+ vertices = [
358
+ (-s, -s, -s), (s, -s, -s), (s, -s, s), (-s, -s, s),
359
+ (0, s, 0)
360
+ ]
361
+ edges = [
362
+ (0,1), (1,2), (2,3), (3,0),
363
+ (0,4), (1,4), (2,4), (3,4)
364
+ ]
365
+ points = []
366
+ projected = []
367
+ for v in vertices:
368
+ x2d, y2d = Render._project_3d(v[0], v[1], v[2], rotx, roty, rotz, scale, cx, cy)
369
+ projected.append((x2d, y2d))
370
+ for edge in edges:
371
+ x1, y1 = projected[edge[0]]
372
+ x2, y2 = projected[edge[1]]
373
+ points.extend(Render.get_line_points(x1, y1, x2, y2, char))
374
+ return Render._apply_color(points, color, bg)
375
+
376
+ @staticmethod
377
+ def get_cylinder_3d_points(radius=5, height=10, segments=12, rotx=0, roty=0, rotz=0, scale=10, cx=40, cy=12, char='#', color=None, bg=None):
378
+ points = []
379
+ top_points = []
380
+ bottom_points = []
381
+ for i in range(segments):
382
+ rad = i / segments * 2 * math.pi
383
+ x = radius * math.cos(rad)
384
+ z = radius * math.sin(rad)
385
+ top_points.append((x, height/2, z))
386
+ bottom_points.append((x, -height/2, z))
387
+ all_vertices = top_points + bottom_points
388
+ projected = []
389
+ for v in all_vertices:
390
+ x2d, y2d = Render._project_3d(v[0], v[1], v[2], rotx, roty, rotz, scale, cx, cy)
391
+ projected.append((x2d, y2d))
392
+ for i in range(segments):
393
+ j = (i + 1) % segments
394
+ x1, y1 = projected[i]
395
+ x2, y2 = projected[j]
396
+ points.extend(Render.get_line_points(x1, y1, x2, y2, char))
397
+ x1, y1 = projected[i + segments]
398
+ x2, y2 = projected[j + segments]
399
+ points.extend(Render.get_line_points(x1, y1, x2, y2, char))
400
+ x1, y1 = projected[i]
401
+ x2, y2 = projected[i + segments]
402
+ points.extend(Render.get_line_points(x1, y1, x2, y2, char))
403
+ return Render._apply_color(points, color, bg)
404
+
405
+ @staticmethod
406
+ def get_sphere_3d_points(radius=5, segments=12, rotx=0, roty=0, rotz=0, scale=10, cx=40, cy=12, char='#', color=None, bg=None):
407
+ points = []
408
+ for i in range(segments):
409
+ theta = i / segments * math.pi
410
+ for j in range(segments):
411
+ phi = j / segments * 2 * math.pi
412
+ x = radius * math.sin(theta) * math.cos(phi)
413
+ y = radius * math.cos(theta)
414
+ z = radius * math.sin(theta) * math.sin(phi)
415
+ x2d, y2d = Render._project_3d(x, y, z, rotx, roty, rotz, scale, cx, cy)
416
+ points.append((x2d, y2d, char))
417
+ return Render._apply_color(points, color, bg)
418
+
419
+ @staticmethod
420
+ def get_cone_3d_points(radius=5, height=10, segments=12, rotx=0, roty=0, rotz=0, scale=10, cx=40, cy=12, char='#', color=None, bg=None):
421
+ points = []
422
+ base_points = []
423
+ for i in range(segments):
424
+ rad = i / segments * 2 * math.pi
425
+ x = radius * math.cos(rad)
426
+ z = radius * math.sin(rad)
427
+ base_points.append((x, -height/2, z))
428
+ apex = (0, height/2, 0)
429
+ all_vertices = base_points + [apex]
430
+ projected = []
431
+ for v in all_vertices:
432
+ x2d, y2d = Render._project_3d(v[0], v[1], v[2], rotx, roty, rotz, scale, cx, cy)
433
+ projected.append((x2d, y2d))
434
+ for i in range(segments):
435
+ j = (i + 1) % segments
436
+ x1, y1 = projected[i]
437
+ x2, y2 = projected[j]
438
+ points.extend(Render.get_line_points(x1, y1, x2, y2, char))
439
+ x1, y1 = projected[i]
440
+ x2, y2 = projected[-1]
441
+ points.extend(Render.get_line_points(x1, y1, x2, y2, char))
442
+ return Render._apply_color(points, color, bg)
443
+
444
+ @staticmethod
445
+ def get_torus_3d_points(radius=8, tube=3, segments=16, rotx=0, roty=0, rotz=0, scale=10, cx=40, cy=12, char='#', color=None, bg=None):
446
+ points = []
447
+ for i in range(segments):
448
+ theta = i / segments * 2 * math.pi
449
+ for j in range(segments):
450
+ phi = j / segments * 2 * math.pi
451
+ x = (radius + tube * math.cos(phi)) * math.cos(theta)
452
+ y = tube * math.sin(phi)
453
+ z = (radius + tube * math.cos(phi)) * math.sin(theta)
454
+ x2d, y2d = Render._project_3d(x, y, z, rotx, roty, rotz, scale, cx, cy)
455
+ points.append((x2d, y2d, char))
456
+ return Render._apply_color(points, color, bg)
457
+
458
+ @staticmethod
459
+ def get_ico_sphere_3d_points(radius=5, rotx=0, roty=0, rotz=0, scale=10, cx=40, cy=12, char='#', color=None, bg=None):
460
+ points = []
461
+ phi = (1 + math.sqrt(5)) / 2
462
+ vertices = [
463
+ (-1, phi, 0), ( 1, phi, 0), (-1, -phi, 0), ( 1, -phi, 0),
464
+ ( 0, -1, phi), ( 0, 1, phi), ( 0, -1, -phi), ( 0, 1, -phi),
465
+ ( phi, 0, -1), ( phi, 0, 1), (-phi, 0, -1), (-phi, 0, 1)
466
+ ]
467
+ vertices = [(x * radius / 1.618, y * radius / 1.618, z * radius / 1.618) for x, y, z in vertices]
468
+ edges = [
469
+ (0,1), (0,4), (0,5), (0,10), (0,11),
470
+ (1,2), (1,5), (1,7), (1,8), (1,9),
471
+ (2,3), (2,6), (2,7), (2,10), (2,11),
472
+ (3,4), (3,6), (3,8), (3,9), (3,11),
473
+ (4,5), (4,9), (4,11),
474
+ (5,7), (5,9),
475
+ (6,7), (6,10),
476
+ (7,8),
477
+ (8,9), (8,10),
478
+ (10,11)
479
+ ]
480
+ projected = []
481
+ for v in vertices:
482
+ x2d, y2d = Render._project_3d(v[0], v[1], v[2], rotx, roty, rotz, scale, cx, cy)
483
+ projected.append((x2d, y2d))
484
+ for edge in edges:
485
+ x1, y1 = projected[edge[0]]
486
+ x2, y2 = projected[edge[1]]
487
+ points.extend(Render.get_line_points(x1, y1, x2, y2, char))
488
+ return Render._apply_color(points, color, bg)
489
+
490
+ class Animate:
491
+ @staticmethod
492
+ def clear_screen():
493
+ os.system("cls" if os.name == "nt" else "clear")
494
+
495
+ @staticmethod
496
+ def animate_print(text, delay=0.05, end="\n", flush=True, backspace_effect=False):
497
+ if backspace_effect:
498
+ for ch in text:
499
+ sys.stdout.write(ch)
500
+ sys.stdout.flush()
501
+ time.sleep(delay)
502
+ time.sleep(0.2)
503
+ for _ in text:
504
+ sys.stdout.write("\b \b")
505
+ sys.stdout.flush()
506
+ time.sleep(delay / 2)
507
+ for ch in text:
508
+ sys.stdout.write(ch)
509
+ sys.stdout.flush()
510
+ time.sleep(delay)
511
+ else:
512
+ for ch in text:
513
+ sys.stdout.write(ch)
514
+ if flush:
515
+ sys.stdout.flush()
516
+ time.sleep(delay)
517
+ sys.stdout.write(end)
518
+ if flush:
519
+ sys.stdout.flush()
520
+
521
+ @staticmethod
522
+ def spinning_cursor(message="Loading", duration=3.0, delay=0.1, frames=None, clear_before=False):
523
+ if clear_before:
524
+ Animate.clear_screen()
525
+ if frames is None:
526
+ frames = ["|", "/", "-", "\\"]
527
+ end_time = time.time() + duration
528
+ idx = 0
529
+ while time.time() < end_time:
530
+ frame = frames[idx % len(frames)]
531
+ sys.stdout.write(f"\r{message} {frame} ")
532
+ sys.stdout.flush()
533
+ time.sleep(delay)
534
+ idx += 1
535
+ sys.stdout.write("\r" + " " * (len(message) + 3) + "\r")
536
+ sys.stdout.flush()
537
+
538
+ @staticmethod
539
+ def countdown(seconds, message="Countdown", clear_before=False):
540
+ if clear_before:
541
+ Animate.clear_screen()
542
+ for remaining in range(seconds, 0, -1):
543
+ sys.stdout.write(f"\r{message}: {remaining:2d} seconds remaining ")
544
+ sys.stdout.flush()
545
+ time.sleep(1)
546
+ sys.stdout.write(f"\r{message}: 0 seconds remaining! Done!\n")
547
+ sys.stdout.flush()
548
+
549
+ class Keyboard:
550
+ @staticmethod
551
+ def GetKey():
552
+ if os.name == "posix":
553
+ try:
554
+ import termios
555
+ import tty
556
+ fd = sys.stdin.fileno()
557
+ old = termios.tcgetattr(fd)
558
+ try:
559
+ tty.setraw(fd)
560
+ ch = sys.stdin.read(1)
561
+ return ch if ch else ""
562
+ finally:
563
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
564
+ except Exception:
565
+ try:
566
+ return sys.stdin.read(1) or ""
567
+ except Exception:
568
+ return ""
569
+
570
+ if os.name == "nt":
571
+ try:
572
+ import msvcrt
573
+ ch = msvcrt.getwch()
574
+ if ch in ("\x00", "\xe0"):
575
+ ch2 = msvcrt.getwch()
576
+ return "\x1b[" + Keyboard._win_ext_to_ansi(ch2)
577
+ return ch
578
+ except Exception:
579
+ try:
580
+ return sys.stdin.read(1) or ""
581
+ except Exception:
582
+ return ""
583
+
584
+ try:
585
+ return sys.stdin.read(1) or ""
586
+ except Exception:
587
+ return ""
588
+
589
+ @staticmethod
590
+ def _win_ext_to_ansi(ch):
591
+ return {
592
+ "H": "A",
593
+ "P": "B",
594
+ "M": "C",
595
+ "K": "D",
596
+ "G": "H",
597
+ "O": "F",
598
+ "I": "5~",
599
+ "Q": "6~",
600
+ "S": "3~",
601
+ "R": "2~",
602
+ }.get(ch, "")
603
+
604
+ @staticmethod
605
+ def GetKeyName():
606
+ k = Keyboard.GetKey()
607
+ if not k:
608
+ return ""
609
+
610
+ if k == "\x1b":
611
+ try:
612
+ k2 = Keyboard.GetKey()
613
+ except Exception:
614
+ return "ESC"
615
+
616
+ if k2 == "[":
617
+ try:
618
+ k3 = Keyboard.GetKey()
619
+ except Exception:
620
+ return "ESC"
621
+
622
+ simple = {
623
+ "A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT",
624
+ "H": "HOME", "F": "END",
625
+ }
626
+ if k3 in simple:
627
+ return simple[k3]
628
+
629
+ if k3.isdigit():
630
+ digits = k3
631
+ try:
632
+ while len(digits) < 3:
633
+ nxt = Keyboard.GetKey()
634
+ if not nxt:
635
+ break
636
+ if nxt == "~":
637
+ break
638
+ if nxt.isdigit():
639
+ digits += nxt
640
+ else:
641
+ break
642
+ except Exception:
643
+ pass
644
+ return {
645
+ "1": "HOME", "2": "INSERT", "3": "DELETE",
646
+ "4": "END", "5": "PGUP", "6": "PGDN",
647
+ "7": "HOME", "8": "END",
648
+ "11": "F1", "12": "F2", "13": "F3", "14": "F4",
649
+ "15": "F5", "17": "F6", "18": "F7", "19": "F8",
650
+ "20": "F9", "21": "F10", "23": "F11", "24": "F12",
651
+ }.get(digits, "ESC")
652
+
653
+ if k3 == "1":
654
+ try:
655
+ Keyboard.GetKey()
656
+ Keyboard.GetKey()
657
+ arrow = Keyboard.GetKey()
658
+ return simple.get(arrow, "ESC")
659
+ except Exception:
660
+ return "ESC"
661
+
662
+ return "ESC"
663
+
664
+ if k2 == "O":
665
+ try:
666
+ k3 = Keyboard.GetKey()
667
+ except Exception:
668
+ return "ESC"
669
+ return {
670
+ "A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT",
671
+ "H": "HOME", "F": "END",
672
+ "P": "F1", "Q": "F2", "R": "F3", "S": "F4",
673
+ }.get(k3, "ESC")
674
+
675
+ return "ESC"
676
+
677
+ if k in ("\r", "\n"):
678
+ return "ENTER"
679
+ if k in ("\x7f", "\x08"):
680
+ return "BACKSPACE"
681
+ if k == "\t":
682
+ return "TAB"
683
+ if k == "\x03":
684
+ return "CTRL_C"
685
+ if k == "\x04":
686
+ return "CTRL_D"
687
+ if k == "\x1a":
688
+ return "CTRL_Z"
689
+
690
+ return k
691
+
692
+ @staticmethod
693
+ def IsRawSupported():
694
+ if os.name == "nt":
695
+ try:
696
+ import msvcrt
697
+ return True
698
+ except Exception:
699
+ return False
700
+ try:
701
+ import termios
702
+ import tty
703
+ fd = sys.stdin.fileno()
704
+ old = termios.tcgetattr(fd)
705
+ try:
706
+ tty.setraw(fd)
707
+ finally:
708
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
709
+ return True
710
+ except Exception:
711
+ return False
712
+
713
+ @staticmethod
714
+ def Flush():
715
+ try:
716
+ if os.name == "nt":
717
+ import msvcrt
718
+ while msvcrt.kbhit():
719
+ msvcrt.getwch()
720
+ else:
721
+ import termios
722
+ fd = sys.stdin.fileno()
723
+ termios.tcflush(fd, termios.TCIFLUSH)
724
+ except Exception:
725
+ pass
726
+
727
+ class Widget:
728
+ def __init__(self, x=0, y=0, w=0, h=0):
729
+ self.x = x
730
+ self.y = y
731
+ self.w = w
732
+ self.h = h
733
+ self.focusable = False
734
+ self.focused = False
735
+ self.visible = True
736
+ self.parent = None
737
+ self.children = []
738
+ self.style = None
739
+
740
+ def add(self, widget):
741
+ widget.parent = self
742
+ self.children.append(widget)
743
+ return widget
744
+
745
+ def remove(self, widget):
746
+ if widget in self.children:
747
+ self.children.remove(widget)
748
+ widget.parent = None
749
+
750
+ def draw(self, canvas):
751
+ for child in self.children:
752
+ if child.visible:
753
+ child.draw(canvas)
754
+
755
+ def handle_key(self, key):
756
+ for child in self.children:
757
+ if child.focused and child.handle_key(key):
758
+ return True
759
+ return False
760
+
761
+ def handle_mouse(self, event):
762
+ for child in self.children:
763
+ if child.contains(event.x, event.y):
764
+ if child.handle_mouse(event):
765
+ return True
766
+ return False
767
+
768
+ def contains(self, x, y):
769
+ return (self.x <= x < self.x + self.w and
770
+ self.y <= y < self.y + self.h)
771
+
772
+ def move(self, x, y):
773
+ self.x = x
774
+ self.y = y
775
+
776
+ def resize(self, w, h):
777
+ self.w = w
778
+ self.h = h
779
+
780
+ def focus(self):
781
+ self.focused = True
782
+
783
+ def blur(self):
784
+ self.focused = False
785
+
786
+ class ConsoleInfo:
787
+ @staticmethod
788
+ def GetConsole():
789
+ return sys.stdout
790
+
791
+ @staticmethod
792
+ def GetPipVer():
793
+ try:
794
+ result = CMD.run(['python', '-m', 'pip', '--version'], capture_output=True, text=True, check=True)
795
+ return result.stdout.strip()
796
+ except:
797
+ return "Error: pip not found"
798
+ @staticmethod
799
+ def GetSize():
800
+ import shutil
801
+ return shutil.get_terminal_size((80, 24))
802
+
803
+ @staticmethod
804
+ def GetWidth():
805
+ import shutil
806
+ return shutil.get_terminal_size((80, 24)).columns
807
+
808
+ @staticmethod
809
+ def GetHeight():
810
+ import shutil
811
+ return shutil.get_terminal_size((80, 24)).lines
812
+
813
+ class Process:
814
+ @staticmethod
815
+ def StartPyCode(code, wait=False, args=None, python="python"):
816
+ try:
817
+ cmd = [python, "-c", code]
818
+ if args:
819
+ cmd.extend(args)
820
+ if wait:
821
+ proc = CMD.run(cmd, capture_output=True, text=True)
822
+ return proc
823
+ else:
824
+ proc = CMD.Popen(cmd)
825
+ return proc
826
+ except Exception as e:
827
+ print(f"Error executing code: {e}")
828
+ return None
829
+
830
+ @staticmethod
831
+ def KillPyScript(pid):
832
+ try:
833
+ os.kill(pid, 15)
834
+ time.sleep(0.2)
835
+ return True
836
+ except ProcessLookupError:
837
+ print(f"Process {pid} not found")
838
+ return False
839
+ except PermissionError:
840
+ print(f"Permission denied to kill process {pid}")
841
+ return False
842
+ except Exception as e:
843
+ print(f"Error killing process: {e}")
844
+ return False
845
+
846
+ @staticmethod
847
+ def KillPyScriptForce(pid):
848
+ try:
849
+ os.kill(pid, 9)
850
+ return True
851
+ except ProcessLookupError:
852
+ print(f"Process {pid} not found")
853
+ return False
854
+ except PermissionError:
855
+ print(f"Permission denied to kill process {pid}")
856
+ return False
857
+ except Exception as e:
858
+ print(f"Error killing process: {e}")
859
+ return False
860
+
861
+ @staticmethod
862
+ def IsRunning(pid):
863
+ try:
864
+ os.kill(pid, 0)
865
+ return True
866
+ except ProcessLookupError:
867
+ return False
868
+ except PermissionError:
869
+ return True
870
+
871
+ @staticmethod
872
+ def GetProcessList():
873
+ try:
874
+ result = CMD.run(['ps', '-e', '-o', 'pid,comm'], capture_output=True, text=True)
875
+ lines = result.stdout.strip().split('\n')[1:]
876
+ processes = []
877
+ for line in lines:
878
+ parts = line.strip().split(' ', 1)
879
+ if len(parts) == 2:
880
+ pid = int(parts[0])
881
+ name = parts[1]
882
+ processes.append({'pid': pid, 'name': name})
883
+ return processes
884
+ except Exception:
885
+ return []
886
+
887
+ @staticmethod
888
+ def GetProcessInfo(pid):
889
+ try:
890
+ result = CMD.run(['ps', '-p', str(pid), '-o', 'pid,comm,%cpu,%mem,etime'], capture_output=True, text=True)
891
+ lines = result.stdout.strip().split('\n')
892
+ if len(lines) < 2:
893
+ return None
894
+ parts = lines[1].strip().split()
895
+ if len(parts) >= 5:
896
+ return {
897
+ 'pid': int(parts[0]),
898
+ 'name': parts[1],
899
+ 'cpu': parts[2],
900
+ 'memory': parts[3],
901
+ 'time': parts[4]
902
+ }
903
+ return None
904
+ except Exception:
905
+ return None
906
+
907
+ @staticmethod
908
+ def WaitForProcess(pid, timeout=None):
909
+ start_time = time.time()
910
+ while Process.IsRunning(pid):
911
+ if timeout and (time.time() - start_time) > timeout:
912
+ return False
913
+ time.sleep(0.5)
914
+ return True
915
+
916
+ @staticmethod
917
+ def GetCurrentPID():
918
+ return os.getpid()
919
+
920
+ class Channel:
921
+ def __init__(self):
922
+ self.connection = None
923
+ self.host = None
924
+ self.port = None
925
+ self.is_server = False
926
+
927
+ @staticmethod
928
+ def _get(url, timeout=5):
929
+ try:
930
+ import urllib.request
931
+ with urllib.request.urlopen(url, timeout=timeout) as response:
932
+ return response.read().decode()
933
+ except Exception:
934
+ return None
935
+
936
+ @staticmethod
937
+ def GetPublicIP():
938
+ ip = Channel._get('https://api.ipify.org')
939
+ return ip
940
+
941
+ def Connect(self, host='127.0.0.1', port=54321):
942
+ self.host = host
943
+ self.port = port
944
+ try:
945
+ self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
946
+ self.connection.connect((host, port))
947
+ return self.connection
948
+ except Exception as e:
949
+ print(f"[Error] Connection failed: {e}")
950
+ return None
951
+
952
+ def ConnectPublic(self, port=54321):
953
+ public_ip = Channel.GetPublicIP()
954
+ if not public_ip:
955
+ print("[Error] Could not get public IP")
956
+ return None
957
+ return self.Connect(host=public_ip, port=port)
958
+
959
+ def CreateServer(self, port=54321):
960
+ self.is_server = True
961
+ self.port = port
962
+ try:
963
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
964
+ server.bind(('0.0.0.0', port))
965
+ server.listen(1)
966
+ self.connection, addr = server.accept()
967
+ return self.connection
968
+ except Exception as e:
969
+ print(f"[Error] Server failed: {e}")
970
+ return None
971
+
972
+ def Disconnect(self):
973
+ if self.connection:
974
+ self.connection.close()
975
+ self.connection = None
976
+
977
+ def Send(self, message):
978
+ if not self.connection:
979
+ return False
980
+ try:
981
+ if isinstance(message, str):
982
+ message = message.encode()
983
+ self.connection.send(message + b'\n')
984
+ return True
985
+ except Exception:
986
+ return False
987
+
988
+ def Receive(self):
989
+ if not self.connection:
990
+ return None
991
+ try:
992
+ data = self.connection.recv(1024).decode().strip()
993
+ return data
994
+ except Exception:
995
+ return None
996
+
997
+ class Web:
998
+ _server = None
999
+ _thread = None
1000
+ @staticmethod
1001
+ def StartHTMLCode(html_content, port=None, auto_open=True):
1002
+ if port is None:
1003
+ port = Web._get_free_port()
1004
+ class Handler(BaseHTTPRequestHandler):
1005
+ def do_GET(self):
1006
+ self.send_response(200)
1007
+ self.send_header("Content-type", "text/html")
1008
+ self.end_headers()
1009
+ self.wfile.write(html_content.encode("utf-8"))
1010
+ def log_message(self, format, *args):
1011
+ pass
1012
+ Web._server = HTTPServer(("127.0.0.1", port), Handler)
1013
+ def run_server():
1014
+ print(f"[Web] Server running on http://localhost:{port}")
1015
+ Web._server.serve_forever()
1016
+ Web._thread = threading.Thread(target=run_server, daemon=True)
1017
+ Web._thread.start()
1018
+ if auto_open:
1019
+ webbrowser.open(f"http://localhost:{port}")
1020
+ return port
1021
+
1022
+ @staticmethod
1023
+ def KillHTMLCode():
1024
+ if Web._server is not None:
1025
+ Web._server.shutdown()
1026
+ Web._server.server_close()
1027
+ Web._server = None
1028
+ Web._thread = None
1029
+ return True
1030
+ return False
1031
+
1032
+ @staticmethod
1033
+ def _get_free_port():
1034
+ import socket
1035
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1036
+ s.bind(("", 0))
1037
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1038
+ return s.getsockname()[1]
1039
+
1040
+ # ─────────────────────────────────────────────
1041
+ # Canvas — fixed-layout buffer for GUI rendering
1042
+ # ─────────────────────────────────────────────
1043
+ class Canvas:
1044
+ def __init__(self, width, height, fill=' '):
1045
+ self.w = width
1046
+ self.h = height
1047
+ self.buf = [fill] * (width * height)
1048
+ self.fg = {}
1049
+ self.bg = {}
1050
+
1051
+ def clear(self, fill=' '):
1052
+ self.buf = [fill] * (self.w * self.h)
1053
+ self.fg.clear()
1054
+ self.bg.clear()
1055
+
1056
+ def set(self, x, y, char, color=None, bg=None):
1057
+ if 0 <= x < self.w and 0 <= y < self.h:
1058
+ idx = y * self.w + x
1059
+ self.buf[idx] = char[0] if char else ' '
1060
+ if color:
1061
+ self.fg[idx] = color
1062
+ else:
1063
+ self.fg.pop(idx, None)
1064
+ if bg:
1065
+ self.bg[idx] = bg
1066
+ else:
1067
+ self.bg.pop(idx, None)
1068
+
1069
+ def get(self, x, y):
1070
+ if 0 <= x < self.w and 0 <= y < self.h:
1071
+ return self.buf[y * self.w + x]
1072
+ return ' '
1073
+
1074
+ def text(self, x, y, s, color=None, bg=None):
1075
+ for i, ch in enumerate(s):
1076
+ self.set(x + i, y, ch, color, bg)
1077
+
1078
+ def hline(self, x1, x2, y, char='─', color=None, bg=None):
1079
+ if x2 < x1:
1080
+ x1, x2 = x2, x1
1081
+ for x in range(x1, x2 + 1):
1082
+ self.set(x, y, char, color, bg)
1083
+
1084
+ def vline(self, x, y1, y2, char='│', color=None, bg=None):
1085
+ if y2 < y1:
1086
+ y1, y2 = y2, y1
1087
+ for y in range(y1, y2 + 1):
1088
+ self.set(x, y, char, color, bg)
1089
+
1090
+ def rect(self, x1, y1, x2, y2, color=None, bg=None):
1091
+ if x2 < x1:
1092
+ x1, x2 = x2, x1
1093
+ if y2 < y1:
1094
+ y1, y2 = y2, y1
1095
+ self.hline(x1 + 1, x2 - 1, y1, '─', color, bg)
1096
+ self.hline(x1 + 1, x2 - 1, y2, '─', color, bg)
1097
+ self.vline(x1, y1 + 1, y2 - 1, '│', color, bg)
1098
+ self.vline(x2, y1 + 1, y2 - 1, '│', color, bg)
1099
+ self.set(x1, y1, '┌', color, bg)
1100
+ self.set(x2, y1, '┐', color, bg)
1101
+ self.set(x1, y2, '└', color, bg)
1102
+ self.set(x2, y2, '┘', color, bg)
1103
+
1104
+ def fill_rect(self, x1, y1, x2, y2, char=' ', color=None, bg=None):
1105
+ if x2 < x1:
1106
+ x1, x2 = x2, x1
1107
+ if y2 < y1:
1108
+ y1, y2 = y2, y1
1109
+ for y in range(y1, y2 + 1):
1110
+ for x in range(x1, x2 + 1):
1111
+ self.set(x, y, char, color, bg)
1112
+
1113
+ def render(self):
1114
+ lines = []
1115
+ for y in range(self.h):
1116
+ row = []
1117
+ for x in range(self.w):
1118
+ idx = y * self.w + x
1119
+ ch = self.buf[idx]
1120
+ fg = self.fg.get(idx)
1121
+ bg = self.bg.get(idx)
1122
+ if fg or bg:
1123
+ row.append(f"{fg or ''}{bg or ''}{ch}{Colors.RESET}")
1124
+ else:
1125
+ row.append(ch)
1126
+ lines.append(''.join(row))
1127
+ return '\n'.join(lines)
1128
+
1129
+
1130
+ def show(self):
1131
+ sys.stdout.write("\033[H")
1132
+ sys.stdout.write(self.render())
1133
+ sys.stdout.write("\033[J")
1134
+ sys.stdout.flush()
1135
+
1136
+ class Cursor:
1137
+ @staticmethod
1138
+ def move(x, y):
1139
+ sys.stdout.write(f"\033[{y};{x}H")
1140
+ sys.stdout.flush()
1141
+
1142
+ @staticmethod
1143
+ def up(n=1):
1144
+ sys.stdout.write(f"\033[{n}A")
1145
+ sys.stdout.flush()
1146
+
1147
+ @staticmethod
1148
+ def down(n=1):
1149
+ sys.stdout.write(f"\033[{n}B")
1150
+ sys.stdout.flush()
1151
+
1152
+ @staticmethod
1153
+ def clear_line():
1154
+ sys.stdout.write("\r\033[K")
1155
+ sys.stdout.flush()
1156
+
1157
+ @staticmethod
1158
+ def clear_screen():
1159
+ sys.stdout.write("\033[2J\033[H")
1160
+ sys.stdout.flush()
1161
+
1162
+ @staticmethod
1163
+ def hide():
1164
+ sys.stdout.write("\033[?25l")
1165
+ sys.stdout.flush()
1166
+
1167
+ @staticmethod
1168
+ def show():
1169
+ sys.stdout.write("\033[?25h")
1170
+ sys.stdout.flush()
1171
+
1172
+ class Layout:
1173
+ ABSOLUTE = "absolute"
1174
+ VSTACK = "vstack"
1175
+ HSTACK = "hstack"
1176
+ GRID = "grid"
1177
+
1178
+ def __init__(self, mode=ABSOLUTE, padding=0, spacing=0):
1179
+ self.mode = mode
1180
+ self.padding = padding
1181
+ self.spacing = spacing
1182
+ self.children = []
1183
+
1184
+ def add(self, widget, **opts):
1185
+ self.children.append((widget, opts))
1186
+
1187
+ def arrange(self, area):
1188
+ if self.mode == Layout.ABSOLUTE:
1189
+ return # widgets keep their own x/y
1190
+ if self.mode == Layout.VSTACK:
1191
+ y = area.y + self.padding
1192
+ for w, opts in self.children:
1193
+ h = opts.get("h", w.h)
1194
+ w.x = area.x + self.padding
1195
+ w.y = y
1196
+ w.w = area.w - 2 * self.padding
1197
+ w.h = h
1198
+ y += h + self.spacing
1199
+ elif self.mode == Layout.HSTACK:
1200
+ x = area.x + self.padding
1201
+ for w, opts in self.children:
1202
+ wd = opts.get("w", w.w)
1203
+ w.x = x
1204
+ w.y = area.y + self.padding
1205
+ w.w = wd
1206
+ w.h = area.h - 2 * self.padding
1207
+ x += wd + self.spacing
1208
+ elif self.mode == Layout.GRID:
1209
+ # rows x cols from opts
1210
+ rows = max(opts.get("row", 0) for _, opts in self.children) + 1
1211
+ cols = max(opts.get("col", 0) for _, opts in self.children) + 1
1212
+ cw = (area.w - 2 * self.padding) // cols
1213
+ ch = (area.h - 2 * self.padding) // rows
1214
+ for w, opts in self.children:
1215
+ r, c = opts.get("row", 0), opts.get("col", 0)
1216
+ w.x = area.x + self.padding + c * cw
1217
+ w.y = area.y + self.padding + r * ch
1218
+ w.w = cw
1219
+ w.h = ch
1220
+
1221
+ class Container(Widget):
1222
+ def __init__(self, x=0, y=0, w=0, h=0, layout=None):
1223
+ super().__init__(x, y, w, h)
1224
+ self.layout = layout or Layout()
1225
+ self.children = []
1226
+
1227
+ def add(self, widget, **opts):
1228
+ self.children.append(widget)
1229
+ self.layout.add(widget, **opts)
1230
+ return widget
1231
+
1232
+ def remove(self, widget):
1233
+ self.children.remove(widget)
1234
+
1235
+ def draw(self, canvas):
1236
+ self.layout.arrange(Rect(self.x, self.y, self.w, self.h))
1237
+ for child in self.children:
1238
+ if child.visible:
1239
+ child.draw(canvas)
1240
+
1241
+ def handle_key(self, key):
1242
+ for child in self.children:
1243
+ if child.focused and child.handle_key(key):
1244
+ return True
1245
+ return False
1246
+
1247
+ class Scrollable(Widget):
1248
+ def __init__(self, x, y, w, h, content=None):
1249
+ super().__init__(x, y, w, h)
1250
+ self.content = content or [] # list of (text, color)
1251
+ self.offset = 0
1252
+ self.total = 0
1253
+ self.focusable = True
1254
+
1255
+ def set_content(self, lines):
1256
+ self.content = lines
1257
+ self.total = len(lines)
1258
+ self.clamp()
1259
+
1260
+ def clamp(self):
1261
+ max_off = max(0, self.total - self.h)
1262
+ self.offset = max(0, min(self.offset, max_off))
1263
+
1264
+ def scroll(self, n):
1265
+ self.offset += n
1266
+ self.clamp()
1267
+
1268
+ def draw(self, canvas):
1269
+ visible = self.content[self.offset:self.offset + self.h]
1270
+ for i, item in enumerate(visible):
1271
+ text, color = item if isinstance(item, tuple) else (item, None)
1272
+ canvas.text(self.x, self.y + i, text[:self.w], color or Colors.WHITE)
1273
+ # scrollbar
1274
+ if self.total > self.h:
1275
+ bar_x = self.x + self.w - 1
1276
+ bar_h = max(1, self.h * self.h // self.total)
1277
+ bar_y = self.y + (self.h - bar_h) * self.offset // max(1, self.total - self.h)
1278
+ for i in range(self.h):
1279
+ canvas.set(bar_x, self.y + i, "│", Colors.DIM)
1280
+ for i in range(bar_h):
1281
+ canvas.set(bar_x, bar_y + i, "█", Colors.CYAN)
1282
+
1283
+ def handle_key(self, key):
1284
+ if key == "UP":
1285
+ self.scroll(-1)
1286
+ return True
1287
+ if key == "DOWN":
1288
+ self.scroll(1)
1289
+ return True
1290
+ if key == "PGUP":
1291
+ self.scroll(-self.h)
1292
+ return True
1293
+ if key == "PGDN":
1294
+ self.scroll(self.h)
1295
+ return True
1296
+ if key == "HOME":
1297
+ self.offset = 0
1298
+ return True
1299
+ if key == "END":
1300
+ self.offset = max(0, self.total - self.h)
1301
+ return True
1302
+ return False
1303
+
1304
+ class Label(Widget):
1305
+ def __init__(self, x, y, text, color=None, bg=None):
1306
+ super().__init__(x, y, len(text), 1)
1307
+ self.text = text
1308
+ self.color = color
1309
+ self.bg = bg
1310
+
1311
+ def draw(self, canvas):
1312
+ canvas.text(self.x, self.y, self.text[:self.w], self.color, self.bg)
1313
+
1314
+ class Button(Widget):
1315
+ def __init__(self, x, y, text, on_click=None, color=None):
1316
+ super().__init__(x, y, len(text) + 4, 1)
1317
+ self.text = text
1318
+ self.on_click = on_click
1319
+ self.color = color or Colors.CYAN
1320
+ self.focusable = True
1321
+
1322
+ def draw(self, canvas):
1323
+ label = f"[ {self.text} ]"
1324
+ c = (Colors.BG_BLUE + Colors.WHITE) if self.focused else self.color
1325
+ canvas.text(self.x, self.y, label, c)
1326
+
1327
+ def handle_key(self, key):
1328
+ if key == "ENTER" and self.on_click:
1329
+ self.on_click()
1330
+ return True
1331
+ return False
1332
+
1333
+ class Menu(Widget):
1334
+ def __init__(self, x, y, items, on_select=None, width=None, color=None):
1335
+ self.items = items
1336
+ self.selected = 0
1337
+ self.on_select = on_select
1338
+ self.color = color or Colors.WHITE
1339
+ w = width or (max(len(l) for _, l in items) + 4)
1340
+ super().__init__(x, y, w, len(items))
1341
+ self.focusable = True
1342
+
1343
+ def draw(self, canvas):
1344
+ for i, (_, label) in enumerate(self.items):
1345
+ y = self.y + i
1346
+ if i == self.selected and self.focused:
1347
+ canvas.fill_rect(self.x, y, self.x + self.w - 1, y, " ",
1348
+ Colors.WHITE, Colors.BG_BLUE)
1349
+ canvas.text(self.x + 1, y, f"▸ {label}",
1350
+ Colors.WHITE + Colors.BOLD, Colors.BG_BLUE)
1351
+ else:
1352
+ canvas.text(self.x + 2, y, label, self.color)
1353
+
1354
+ def handle_key(self, key):
1355
+ if key == "UP":
1356
+ self.selected = (self.selected - 1) % len(self.items)
1357
+ return True
1358
+ if key == "DOWN":
1359
+ self.selected = (self.selected + 1) % len(self.items)
1360
+ return True
1361
+ if key == "ENTER" and self.on_select:
1362
+ self.on_select(self.items[self.selected][0])
1363
+ return True
1364
+ return False
1365
+
1366
+ class Panel(Widget):
1367
+ def __init__(self, x, y, w, h, title="", color=None):
1368
+ super().__init__(x, y, w, h)
1369
+ self.title = title
1370
+ self.color = color or Colors.CYAN
1371
+
1372
+ def draw(self, canvas):
1373
+ canvas.rect(self.x, self.y, self.x + self.w - 1,
1374
+ self.y + self.h - 1, self.color)
1375
+ if self.title:
1376
+ canvas.text(self.x + 2, self.y, f" {self.title} ",
1377
+ self.color + Colors.BOLD)
1378
+
1379
+ class Input(Widget):
1380
+ def __init__(self, x, y, w, prompt="", on_submit=None, color=None):
1381
+ super().__init__(x, y, w, 1)
1382
+ self.prompt = prompt
1383
+ self.buf = ""
1384
+ self.on_submit = on_submit
1385
+ self.color = color or Colors.WHITE
1386
+ self.focusable = True
1387
+
1388
+ def draw(self, canvas):
1389
+ canvas.text(self.x, self.y, self.prompt, Colors.GREEN + Colors.BOLD)
1390
+ px = self.x + len(self.prompt)
1391
+ canvas.text(px, self.y, self.buf[:max(0, self.w - len(self.prompt))],
1392
+ self.color)
1393
+ cx = px + len(self.buf)
1394
+ if cx < self.x + self.w:
1395
+ canvas.set(cx, self.y, "█", Colors.GREEN)
1396
+
1397
+ def handle_key(self, key):
1398
+ if key == "ENTER":
1399
+ if self.on_submit:
1400
+ self.on_submit(self.buf)
1401
+ self.buf = ""
1402
+ return True
1403
+ if key == "BACKSPACE":
1404
+ self.buf = self.buf[:-1]
1405
+ return True
1406
+ if len(key) == 1 and key >= " ":
1407
+ self.buf += key
1408
+ return True
1409
+ return False
1410
+
1411
+ class HeaderBar(Widget):
1412
+ def __init__(self, x, y, w, left="", right="", bg=None, fg=None):
1413
+ super().__init__(x, y, w, 1)
1414
+ self.left = left
1415
+ self.right = right
1416
+ self.bg = bg or Colors.BG_MAGENTA
1417
+ self.fg = fg or Colors.WHITE
1418
+
1419
+ def draw(self, canvas):
1420
+ canvas.fill_rect(self.x, self.y, self.x + self.w - 1, self.y, " ",
1421
+ self.fg, self.bg)
1422
+ canvas.text(self.x + 1, self.y, self.left,
1423
+ self.fg + Colors.BOLD, self.bg)
1424
+ rx = self.x + self.w - len(self.right) - 2
1425
+ if rx > self.x + len(self.left) + 2:
1426
+ canvas.text(rx, self.y, self.right, self.fg, self.bg)
1427
+
1428
+ class StatusBar(Widget):
1429
+ def __init__(self, x, y, w, left="", mid="", right="", bg=None):
1430
+ super().__init__(x, y, w, 1)
1431
+ self.left = left
1432
+ self.mid = mid
1433
+ self.right = right
1434
+ self.bg = bg or Colors.BG_BLACK
1435
+
1436
+ def draw(self, canvas):
1437
+ canvas.fill_rect(self.x, self.y, self.x + self.w - 1, self.y, " ",
1438
+ Colors.WHITE, self.bg)
1439
+ canvas.text(self.x, self.y, self.left, Colors.YELLOW, self.bg)
1440
+ canvas.text(self.x + 20, self.y, self.mid, Colors.CYAN, self.bg)
1441
+ rx = self.x + self.w - len(self.right) - 1
1442
+ if rx > self.x:
1443
+ canvas.text(rx, self.y, self.right, Colors.GREEN, self.bg)
1444
+
1445
+ class Table(Widget):
1446
+ def __init__(self, x, y, w, h, headers=None, rows=None, color=None):
1447
+ super().__init__(x, y, w, h)
1448
+ self.headers = headers or []
1449
+ self.rows = rows or []
1450
+ self.color = color or Colors.WHITE
1451
+ self.selected = 0
1452
+
1453
+ def draw(self, canvas):
1454
+ n = len(self.headers)
1455
+ if n == 0:
1456
+ return
1457
+ col_w = self.w // n
1458
+ for i, h in enumerate(self.headers):
1459
+ canvas.fill_rect(self.x + i * col_w, self.y,
1460
+ self.x + (i + 1) * col_w - 1, self.y,
1461
+ " ", Colors.WHITE, Colors.BG_BLUE)
1462
+ canvas.text(self.x + i * col_w + 1, self.y, h[:col_w - 2],
1463
+ Colors.WHITE + Colors.BOLD, Colors.BG_BLUE)
1464
+ for r, row in enumerate(self.rows[:self.h - 1]):
1465
+ y = self.y + 1 + r
1466
+ bg = Colors.BG_BLACK if r == self.selected else None
1467
+ fg = Colors.YELLOW if r == self.selected else self.color
1468
+ for i, cell in enumerate(row[:n]):
1469
+ canvas.text(self.x + i * col_w + 1, y, str(cell)[:col_w - 2], fg, bg)
1470
+
1471
+ def handle_key(self, key):
1472
+ if key == "UP":
1473
+ self.selected = max(0, self.selected - 1); return True
1474
+ if key == "DOWN":
1475
+ self.selected = min(len(self.rows) - 1, self.selected + 1); return True
1476
+ return False
1477
+
1478
+ class ProgressBar(Widget):
1479
+ def __init__(self, x, y, w, value=0, maximum=100,
1480
+ fill_char="█", empty_char="░", color=None):
1481
+ super().__init__(x, y, w, 1)
1482
+ self.value = value
1483
+ self.maximum = maximum
1484
+ self.fill_char = fill_char
1485
+ self.empty_char = empty_char
1486
+ self.color = color or Colors.GREEN
1487
+
1488
+ @property
1489
+ def percent(self):
1490
+ if self.maximum <= 0:
1491
+ return 0
1492
+ return max(0.0, min(1.0, self.value / self.maximum))
1493
+
1494
+ def draw(self, canvas):
1495
+ filled = int(self.percent * self.w)
1496
+ canvas.text(self.x, self.y, self.fill_char * filled, self.color)
1497
+ canvas.text(self.x + filled, self.y,
1498
+ self.empty_char * (self.w - filled), Colors.DIM)
1499
+ label = f"{int(self.percent * 100)}%"
1500
+ lx = self.x + (self.w - len(label)) // 2
1501
+ canvas.text(lx, self.y, label, Colors.WHITE + Colors.BOLD)
1502
+
1503
+ def set(self, value):
1504
+ self.value = value
1505
+
1506
+ class Checkbox(Widget):
1507
+ def __init__(self, x, y, text, checked=False, on_toggle=None, color=None):
1508
+ super().__init__(x, y, len(text) + 4, 1)
1509
+ self.text = text
1510
+ self.checked = checked
1511
+ self.on_toggle = on_toggle
1512
+ self.color = color or Colors.WHITE
1513
+ self.focusable = True
1514
+
1515
+ def draw(self, canvas):
1516
+ mark = "[x]" if self.checked else "[ ]"
1517
+ c = Colors.BG_BLUE + Colors.WHITE if self.focused else self.color
1518
+ canvas.text(self.x, self.y, f"{mark} {self.text}", c)
1519
+
1520
+ def handle_key(self, key):
1521
+ if key in ("ENTER", " "):
1522
+ self.checked = not self.checked
1523
+ if self.on_toggle:
1524
+ self.on_toggle(self.checked)
1525
+ return True
1526
+ return False
1527
+
1528
+ class RadioGroup(Widget):
1529
+ def __init__(self, x, y, options, selected=0, on_change=None, color=None):
1530
+ super().__init__(x, y, max(len(o) for o in options) + 4, len(options))
1531
+ self.options = options
1532
+ self.selected = selected
1533
+ self.on_change = on_change
1534
+ self.color = color or Colors.WHITE
1535
+ self.focusable = True
1536
+
1537
+ def draw(self, canvas):
1538
+ for i, opt in enumerate(self.options):
1539
+ mark = "(o)" if i == self.selected else "( )"
1540
+ c = Colors.BG_BLUE + Colors.WHITE if self.focused else self.color
1541
+ canvas.text(self.x, self.y + i, f"{mark} {opt}", c)
1542
+
1543
+ def handle_key(self, key):
1544
+ if key == "UP":
1545
+ self.selected = (self.selected - 1) % len(self.options)
1546
+ if self.on_change: self.on_change(self.selected)
1547
+ return True
1548
+ if key == "DOWN":
1549
+ self.selected = (self.selected + 1) % len(self.options)
1550
+ if self.on_change: self.on_change(self.selected)
1551
+ return True
1552
+ return False
1553
+
1554
+ class MessageBox(Widget):
1555
+ ICONS = {
1556
+ "info": ("i", Colors.BLUE),
1557
+ "warning": ("!", Colors.YELLOW),
1558
+ "error": ("x", Colors.RED),
1559
+ "question": ("?", Colors.CYAN),
1560
+ }
1561
+
1562
+ def __init__(self, canvas, title, message,
1563
+ kind="info",
1564
+ buttons=("OK",),
1565
+ on_result=None):
1566
+ self.canvas_ref = canvas
1567
+ self.title = title
1568
+ self.message = message
1569
+ self.kind = kind
1570
+ self.buttons = list(buttons)
1571
+ self.on_result = on_result
1572
+ self.result = None
1573
+ self.selected = 0
1574
+ self.done = False
1575
+
1576
+ W = canvas.w
1577
+ H = canvas.h
1578
+
1579
+ msg_lines = message.split("\n")
1580
+ msg_w = max(len(l) for l in msg_lines) if msg_lines else 0
1581
+ icon_w = 4
1582
+ btn_w = sum(len(b) + 4 for b in self.buttons) + 2 * (len(self.buttons) - 1)
1583
+ content_w = max(msg_w, btn_w) + icon_w + 6
1584
+ w = min(W - 4, max(40, content_w))
1585
+ h = 6 + len(msg_lines)
1586
+ x = (W - w) // 2
1587
+ y = (H - h) // 2
1588
+
1589
+ super().__init__(x, y, w, h)
1590
+ self.focusable = True
1591
+
1592
+ def draw(self, canvas):
1593
+ x, y, w, h = self.x, self.y, self.w, self.h
1594
+
1595
+ canvas.fill_rect(x, y, x + w - 1, y + h - 1, " ",
1596
+ Colors.WHITE, Colors.BG_BLACK)
1597
+ canvas.rect(x, y, x + w - 1, y + h - 1, Colors.CYAN)
1598
+
1599
+ title = f" {self.title} "
1600
+ canvas.text(x + 2, y, title, Colors.CYAN + Colors.BOLD)
1601
+
1602
+ icon_ch, icon_color = self.ICONS.get(self.kind, ("i", Colors.BLUE))
1603
+ canvas.text(x + 3, y + 2, f" {icon_ch} ", icon_color + Colors.BOLD)
1604
+
1605
+ for i, line in enumerate(self.message.split("\n")):
1606
+ canvas.text(x + 6, y + 2 + i, line[:w - 8], Colors.WHITE)
1607
+
1608
+ btn_y = y + h - 2
1609
+ total = sum(len(b) + 4 for b in self.buttons) + 2 * (len(self.buttons) - 1)
1610
+ bx = x + (w - total) // 2
1611
+ for i, label in enumerate(self.buttons):
1612
+ txt = f"[ {label} ]"
1613
+ if i == self.selected:
1614
+ c = Colors.WHITE + Colors.BOLD
1615
+ bg = Colors.BG_BLUE
1616
+ canvas.fill_rect(bx, btn_y, bx + len(txt) - 1, btn_y, " ", c, bg)
1617
+ canvas.text(bx, btn_y, txt, c, bg)
1618
+ else:
1619
+ canvas.text(bx, btn_y, txt, Colors.WHITE)
1620
+ bx += len(txt) + 2
1621
+
1622
+ def handle_key(self, key):
1623
+ if key == "LEFT":
1624
+ self.selected = (self.selected - 1) % len(self.buttons)
1625
+ return True
1626
+ if key == "RIGHT":
1627
+ self.selected = (self.selected + 1) % len(self.buttons)
1628
+ return True
1629
+ if key == "TAB":
1630
+ self.selected = (self.selected + 1) % len(self.buttons)
1631
+ return True
1632
+ if key == "ENTER":
1633
+ self.result = self.buttons[self.selected]
1634
+ self.done = True
1635
+ if self.on_result:
1636
+ self.on_result(self.result)
1637
+ return True
1638
+ if key == "ESC":
1639
+ self.result = self.buttons[-1]
1640
+ self.done = True
1641
+ if self.on_result:
1642
+ self.on_result(self.result)
1643
+ return True
1644
+ return False
1645
+
1646
+ @staticmethod
1647
+ def _modal_loop(canvas, box):
1648
+ while not box.done:
1649
+ box.draw(canvas)
1650
+ canvas.show()
1651
+ key = Keyboard.GetKeyName()
1652
+ if key == "CTRL_C":
1653
+ box.result = box.buttons[-1]
1654
+ box.done = True
1655
+ break
1656
+ box.handle_key(key)
1657
+ return box.result
1658
+
1659
+ @staticmethod
1660
+ def showinfo(canvas, title, message):
1661
+ box = MessageBox(canvas, title, message,
1662
+ kind="info", buttons=("OK",))
1663
+ return MessageBox._modal_loop(canvas, box)
1664
+
1665
+ @staticmethod
1666
+ def showwarning(canvas, title, message):
1667
+ box = MessageBox(canvas, title, message,
1668
+ kind="warning", buttons=("OK",))
1669
+ return MessageBox._modal_loop(canvas, box)
1670
+
1671
+ @staticmethod
1672
+ def showerror(canvas, title, message):
1673
+ box = MessageBox(canvas, title, message,
1674
+ kind="error", buttons=("OK",))
1675
+ return MessageBox._modal_loop(canvas, box)
1676
+
1677
+ @staticmethod
1678
+ def askyesno(canvas, title, message):
1679
+ box = MessageBox(canvas, title, message,
1680
+ kind="question", buttons=("Yes", "No"))
1681
+ return MessageBox._modal_loop(canvas, box) == "Yes"
1682
+
1683
+ @staticmethod
1684
+ def askokcancel(canvas, title, message):
1685
+ box = MessageBox(canvas, title, message,
1686
+ kind="question", buttons=("OK", "Cancel"))
1687
+ return MessageBox._modal_loop(canvas, box) == "OK"
1688
+
1689
+ @staticmethod
1690
+ def askretrycancel(canvas, title, message):
1691
+ box = MessageBox(canvas, title, message,
1692
+ kind="warning", buttons=("Retry", "Cancel"))
1693
+ return MessageBox._modal_loop(canvas, box) == "Retry"
1694
+
1695
+ @staticmethod
1696
+ def ask(canvas, title, message, kind="info", buttons=("OK",)):
1697
+ box = MessageBox(canvas, title, message,
1698
+ kind=kind, buttons=buttons)
1699
+ return MessageBox._modal_loop(canvas, box)
1700
+
1701
+ class EventBus:
1702
+ def __init__(self):
1703
+ self._handlers = {}
1704
+
1705
+ def on(self, event, handler):
1706
+ self._handlers.setdefault(event, []).append(handler)
1707
+
1708
+ def off(self, event, handler):
1709
+ if event in self._handlers and handler in self._handlers[event]:
1710
+ self._handlers[event].remove(handler)
1711
+
1712
+ def emit(self, event, *args, **kwargs):
1713
+ for h in self._handlers.get(event, []):
1714
+ try:
1715
+ h(*args, **kwargs)
1716
+ except Exception:
1717
+ pass
1718
+
1719
+ def clear(self, event=None):
1720
+ if event is None:
1721
+ self._handlers.clear()
1722
+ else:
1723
+ self._handlers.pop(event, None)
1724
+
1725
+ class FocusManager:
1726
+ def __init__(self):
1727
+ self.stack = []
1728
+
1729
+ def register(self, widget):
1730
+ if widget.focusable and widget not in self.stack:
1731
+ self.stack.append(widget)
1732
+
1733
+ def unregister(self, widget):
1734
+ if widget in self.stack:
1735
+ self.stack.remove(widget)
1736
+
1737
+ def next(self):
1738
+ if not self.stack:
1739
+ return
1740
+ current = next((w for w in self.stack if w.focused), None)
1741
+ idx = (self.stack.index(current) + 1) % len(self.stack) if current else 0
1742
+ self._set(self.stack[idx])
1743
+
1744
+ def prev(self):
1745
+ if not self.stack:
1746
+ return
1747
+ current = next((w for w in self.stack if w.focused), None)
1748
+ idx = (self.stack.index(current) - 1) % len(self.stack) if current else 0
1749
+ self._set(self.stack[idx])
1750
+
1751
+ def set(self, widget):
1752
+ self._set(widget)
1753
+
1754
+ def _set(self, widget):
1755
+ for w in self.stack:
1756
+ w.focused = (w is widget)
1757
+
1758
+ class Style:
1759
+ def __init__(self,
1760
+ fg=None, bg=None,
1761
+ header_bg=None, header_fg=None,
1762
+ menu_bg=None, menu_fg=None,
1763
+ menu_sel_bg=None, menu_sel_fg=None,
1764
+ input_fg=None, input_bg=None,
1765
+ status_bg=None, status_fg=None):
1766
+ self.fg = fg or Colors.WHITE
1767
+ self.bg = bg or Colors.BG_BLACK
1768
+ self.header_bg = header_bg or Colors.BG_MAGENTA
1769
+ self.header_fg = header_fg or Colors.WHITE
1770
+ self.menu_bg = menu_bg or Colors.BG_BLACK
1771
+ self.menu_fg = menu_fg or Colors.WHITE
1772
+ self.menu_sel_bg = menu_sel_bg or Colors.BG_BLUE
1773
+ self.menu_sel_fg = menu_sel_fg or Colors.WHITE
1774
+ self.input_fg = input_fg or Colors.WHITE
1775
+ self.input_bg = input_bg or Colors.BG_BLACK
1776
+ self.status_bg = status_bg or Colors.BG_BLACK
1777
+ self.status_fg = status_fg or Colors.CYAN
1778
+
1779
+
1780
+ DEFAULT_STYLE = Style()
1781
+
1782
+ DARK_STYLE = Style(
1783
+ bg=Colors.BG_BLACK, fg=Colors.WHITE,
1784
+ header_bg=Colors.BG_BLUE, header_fg=Colors.WHITE,
1785
+ menu_bg=Colors.BG_BLACK, menu_fg=Colors.WHITE,
1786
+ menu_sel_bg=Colors.BG_MAGENTA, menu_sel_fg=Colors.WHITE,
1787
+ )
1788
+
1789
+ LIGHT_STYLE = Style(
1790
+ bg=Colors.BG_WHITE, fg=Colors.BLACK,
1791
+ header_bg=Colors.BG_BLUE, header_fg=Colors.WHITE,
1792
+ menu_bg=Colors.BG_WHITE, menu_fg=Colors.BLACK,
1793
+ menu_sel_bg=Colors.BG_CYAN, menu_sel_fg=Colors.BLACK,
1794
+ )
1795
+
1796
+ class Theme:
1797
+ current = DEFAULT_STYLE
1798
+
1799
+ @staticmethod
1800
+ def set(style):
1801
+ Theme.current = style
1802
+
1803
+ @staticmethod
1804
+ def get():
1805
+ return Theme.current
1806
+
1807
+ class MouseEvent:
1808
+ def __init__(self, x, y, button, pressed):
1809
+ self.x = x
1810
+ self.y = y
1811
+ self.button = button
1812
+ self.pressed = pressed
1813
+
1814
+ def __repr__(self):
1815
+ state = "press" if self.pressed else "release"
1816
+ return f"MouseEvent({self.x},{self.y},{self.button},{state})"
1817
+
1818
+ class Mouse:
1819
+ @staticmethod
1820
+ def enable():
1821
+ sys.stdout.write("\033[?1000h\033[?1006h")
1822
+ sys.stdout.flush()
1823
+
1824
+ @staticmethod
1825
+ def disable():
1826
+ sys.stdout.write("\033[?1000l\033[?1006l")
1827
+ sys.stdout.flush()
1828
+
1829
+ @staticmethod
1830
+ def parse(seq):
1831
+ m = re.match(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])", seq)
1832
+ if not m:
1833
+ return None
1834
+ b = int(m.group(1))
1835
+ x = int(m.group(2)) - 1
1836
+ y = int(m.group(3)) - 1
1837
+ kind = m.group(4)
1838
+ pressed = (kind == "M")
1839
+
1840
+ if b & 64:
1841
+ button = "wheel_up" if (b & 1) == 0 else "wheel_down"
1842
+ elif (b & 3) == 0:
1843
+ button = "left"
1844
+ elif (b & 3) == 1:
1845
+ button = "middle"
1846
+ elif (b & 3) == 2:
1847
+ button = "right"
1848
+ else:
1849
+ button = "release"
1850
+
1851
+ return MouseEvent(x, y, button, pressed)
1852
+
1853
+ class RenderWidget(Widget):
1854
+ def __init__(self, x, y, w, h):
1855
+ super().__init__(x, y, w, h)
1856
+ self.points = []
1857
+
1858
+ def set_points(self, points):
1859
+ self.points = list(points)
1860
+
1861
+ def add_points(self, points):
1862
+ self.points.extend(points)
1863
+
1864
+ def clear_points(self):
1865
+ self.points = []
1866
+
1867
+ def draw(self, canvas):
1868
+ for item in self.points:
1869
+ if len(item) >= 3:
1870
+ px, py, ch = item[0], item[1], item[2]
1871
+ color = item[3] if len(item) >= 4 else None
1872
+ bg = item[4] if len(item) >= 5 else None
1873
+ if 0 <= px < self.w and 0 <= py < self.h:
1874
+ canvas.set(self.x + px, self.y + py, ch, color, bg)
1875
+
1876
+ class Chart(RenderWidget):
1877
+ def __init__(self, x, y, w, h, color=None):
1878
+ super().__init__(x, y, w, h)
1879
+ self.data = []
1880
+ self.color = color or Colors.GREEN
1881
+
1882
+ def set_data(self, data):
1883
+ self.data = list(data)
1884
+ self._rebuild()
1885
+
1886
+ def _rebuild(self):
1887
+ self.points = []
1888
+ if not self.data:
1889
+ return
1890
+ n = len(self.data)
1891
+ mn = min(self.data)
1892
+ mx = max(self.data)
1893
+ rng = mx - mn or 1
1894
+ for i, v in enumerate(self.data):
1895
+ px = int(i * (self.w - 1) / max(1, n - 1))
1896
+ py = self.h - 1 - int((v - mn) * (self.h - 1) / rng)
1897
+ self.points.append((px, py, "•", self.color))
1898
+
1899
+ def draw(self, canvas):
1900
+ super().draw(canvas)
1901
+
1902
+ class App:
1903
+ def __init__(self, width=None, height=None):
1904
+ self.widgets = []
1905
+ self.focus_index = -1
1906
+ self.running = False
1907
+ self.running = False
1908
+ self.events = EventBus()
1909
+ self.focus_mgr = FocusManager()
1910
+ self.style = Theme.current
1911
+ self.width = width
1912
+ self.height = height
1913
+ self.canvas = None
1914
+ self.overlay = None
1915
+
1916
+ # ── widget management ──
1917
+ def add(self, widget):
1918
+ self.widgets.append(widget)
1919
+ if widget.focusable:
1920
+ self.focus_mgr.register(widget)
1921
+ return widget
1922
+
1923
+ def remove(self, widget):
1924
+ if widget in self.widgets:
1925
+ self.widgets.remove(widget)
1926
+ self.focus_mgr.unregister(widget)
1927
+
1928
+ def clear(self):
1929
+ self.widgets = []
1930
+ self.focus_mgr.stack = []
1931
+
1932
+ def focus(self, widget):
1933
+ self.focus_mgr.set(widget)
1934
+
1935
+ def focus_next(self):
1936
+ self.focus_mgr.next()
1937
+
1938
+ def focus_prev(self):
1939
+ self.focus_mgr.prev()
1940
+
1941
+ # ── overlay (modal dialogs) ──
1942
+ def push_overlay(self, widget):
1943
+ self.overlay = widget
1944
+ if widget.focusable:
1945
+ self.focus_mgr.set(widget)
1946
+
1947
+ def pop_overlay(self):
1948
+ self.overlay = None
1949
+
1950
+ # ── rendering ──
1951
+ def size(self):
1952
+ W = self.width or ConsoleInfo.GetWidth()
1953
+ H = self.height or ConsoleInfo.GetHeight()
1954
+ return W, H
1955
+
1956
+ def draw(self):
1957
+ W, H = self.size()
1958
+ canvas = Canvas(W, H)
1959
+ canvas.fill_rect(0, 0, W - 1, H - 1, " ",
1960
+ self.style.fg, self.style.bg)
1961
+ self.canvas = canvas
1962
+ for w in self.widgets:
1963
+ if w.visible:
1964
+ w.draw(canvas)
1965
+ if self.overlay is not None:
1966
+ self.overlay.draw(canvas)
1967
+ return canvas
1968
+
1969
+ def redraw(self):
1970
+ c = self.draw()
1971
+ c.show()
1972
+
1973
+ # ── event loop ──
1974
+ def handle_key(self, key):
1975
+ if self.overlay is not None:
1976
+ if self.overlay.handle_key(key):
1977
+ if getattr(self.overlay, "done", False):
1978
+ self.pop_overlay()
1979
+ return True
1980
+ return False
1981
+
1982
+ if key == "TAB":
1983
+ self.focus_next()
1984
+ return True
1985
+ if key == "SHIFT_TAB":
1986
+ self.focus_prev()
1987
+ return True
1988
+
1989
+ for w in self.widgets:
1990
+ if w.focused and w.handle_key(key):
1991
+ return True
1992
+ return False
1993
+
1994
+ def run(self, on_key=None, on_tick=None, tick_interval=0.05):
1995
+ self.running = True
1996
+ Cursor.hide()
1997
+ try:
1998
+ while self.running:
1999
+ self.redraw()
2000
+ key = Keyboard.GetKeyName()
2001
+ if key in ("CTRL_C", "CTRL_D"):
2002
+ break
2003
+ if on_key and on_key(key):
2004
+ continue
2005
+ self.handle_key(key)
2006
+ if on_tick:
2007
+ on_tick()
2008
+ finally:
2009
+ Cursor.show()
2010
+
2011
+ def quit(self):
2012
+ self.running = False