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 +2012 -0
- cuifw-1.0.0.dist-info/METADATA +78 -0
- cuifw-1.0.0.dist-info/RECORD +6 -0
- cuifw-1.0.0.dist-info/WHEEL +5 -0
- cuifw-1.0.0.dist-info/licenses/LICENSE +21 -0
- cuifw-1.0.0.dist-info/top_level.txt +1 -0
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
|