cmdgui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cmdgui/shorts.py ADDED
@@ -0,0 +1,307 @@
1
+ import os
2
+ import sys
3
+ import unicodedata
4
+
5
+ ESC = "\x1b["
6
+ RESET = ESC + "0m"
7
+
8
+ # Border character sets: top-left, top-right, bottom-left, bottom-right, horizontal, vertical
9
+ BORDERS = {
10
+ "single": "┌┐└┘─│",
11
+ "double": "╔╗╚╝═║",
12
+ "rounded": "╭╮╰╯─│",
13
+ "heavy": "┏┓┗┛━┃",
14
+ "ascii": "++++-|",
15
+ }
16
+
17
+ # Line-drawing characters by which directions they connect, for borders that
18
+ # meet and cross: bits are UP, DOWN, LEFT, RIGHT
19
+ UP, DOWN, LEFT, RIGHT = 1, 2, 4, 8
20
+ LINE_CHARS = {
21
+ LEFT: "─", RIGHT: "─", LEFT | RIGHT: "─",
22
+ UP: "│", DOWN: "│", UP | DOWN: "│",
23
+ DOWN | RIGHT: "┌", DOWN | LEFT: "┐", UP | RIGHT: "└", UP | LEFT: "┘",
24
+ UP | DOWN | RIGHT: "├", UP | DOWN | LEFT: "┤",
25
+ DOWN | LEFT | RIGHT: "┬", UP | LEFT | RIGHT: "┴",
26
+ UP | DOWN | LEFT | RIGHT: "┼",
27
+ }
28
+
29
+ COLORS = {
30
+ "black": 0, "red": 1, "green": 2, "yellow": 3,
31
+ "blue": 4, "magenta": 5, "cyan": 6, "white": 7,
32
+ }
33
+
34
+ # --- Screen / cursor ------------------------------------------------------
35
+
36
+ def screen_size():
37
+ size = os.get_terminal_size()
38
+ return size.columns, size.lines
39
+
40
+ def move(x, y):
41
+ """Escape code to move the cursor to column x, row y (0-based)."""
42
+ return f"{ESC}{y + 1};{x + 1}H"
43
+
44
+ def hide_cursor(): return ESC + "?25l"
45
+ def show_cursor(): return ESC + "?25h"
46
+ def clear_screen(): return ESC + "2J"
47
+
48
+ def write(s):
49
+ """Write and flush in one go. Uses the real stdout, so drawing still works
50
+ while sys.stdout is being intercepted."""
51
+ sys.__stdout__.write(s)
52
+ sys.__stdout__.flush()
53
+
54
+ # --- Styling ----------------------------------------------------------------
55
+
56
+ def style(fg=None, bg=None, bold=False, dim=False, italic=False, underline=False, reverse=False):
57
+ """Escape code for a style. Colors are names from COLORS, prefixed 'bright_' for bright."""
58
+ codes = []
59
+ if bold: codes.append("1")
60
+ if dim: codes.append("2")
61
+ if italic: codes.append("3")
62
+ if underline: codes.append("4")
63
+ if reverse: codes.append("7")
64
+ if fg: codes.append(str(_color_code(fg, 30)))
65
+ if bg: codes.append(str(_color_code(bg, 40)))
66
+ return f"{ESC}{';'.join(codes)}m" if codes else ""
67
+
68
+ def styled(text, **kwargs):
69
+ """Wrap text in a style and reset after it."""
70
+ s = style(**kwargs)
71
+ return f"{s}{text}{RESET}" if s else text
72
+
73
+ def _color_code(name, base):
74
+ if name.startswith("bright_"): return COLORS[name[7:]] + base + 60
75
+ return COLORS[name] + base
76
+
77
+ # --- Text width -------------------------------------------------------------
78
+ # Emoji and CJK characters take two columns, combining accents take none.
79
+
80
+ def char_width(char):
81
+ if unicodedata.combining(char) or char in "\u200b\u200d\ufe0f":
82
+ return 0
83
+ if unicodedata.east_asian_width(char) in ("W", "F"):
84
+ return 2
85
+ return 1
86
+
87
+ def text_width(text):
88
+ """How many columns text takes on screen."""
89
+ return sum(char_width(c) for c in text)
90
+
91
+ def take(text, width):
92
+ """The longest start of text that fits in width columns."""
93
+ used = 0
94
+ for i, char in enumerate(text):
95
+ used += char_width(char)
96
+ if used > width:
97
+ return text[:i]
98
+ return text
99
+
100
+ # --- Padding / fitting ------------------------------------------------------
101
+
102
+ def pad_left(text, width, char=" "):
103
+ """Right-align text in width (padding goes on the left)."""
104
+ text = fit(text, width)
105
+ return char * (width - text_width(text)) + text
106
+
107
+ def pad_right(text, width, char=" "):
108
+ """Left-align text in width (padding goes on the right)."""
109
+ text = fit(text, width)
110
+ return text + char * (width - text_width(text))
111
+
112
+ def pad_center(text, width, char=" "):
113
+ text = fit(text, width)
114
+ space = width - text_width(text)
115
+ return char * (space // 2) + text + char * (space - space // 2)
116
+
117
+ def fit(text, width, ellipsis="…"):
118
+ """Crop text to width, ending with an ellipsis if it was cut."""
119
+ if text_width(text) <= width: return text
120
+ if width <= len(ellipsis): return take(text, width)
121
+ return take(text, width - len(ellipsis)) + ellipsis
122
+
123
+ def wrap(text, width):
124
+ """Word-wrap text into a list of lines no longer than width."""
125
+ if width <= 0: return []
126
+ lines = []
127
+ for paragraph in text.split("\n"):
128
+ line = ""
129
+ for word in paragraph.split(" "):
130
+ while text_width(word) > width: # hard-break words longer than a line
131
+ if line:
132
+ lines.append(line)
133
+ line = ""
134
+ part = take(word, width) or word[0]
135
+ lines.append(part)
136
+ word = word[len(part):]
137
+ if not line:
138
+ line = word
139
+ elif text_width(line) + 1 + text_width(word) <= width:
140
+ line += " " + word
141
+ else:
142
+ lines.append(line)
143
+ line = word
144
+ lines.append(line)
145
+ return lines
146
+
147
+ # --- Canvas -------------------------------------------------------------------
148
+
149
+ class Canvas:
150
+ """An off-screen grid of characters. Draw into it, then send it to the
151
+ terminal with a single write via draw_to().
152
+
153
+ A wide character takes two cells: the character, then "" in the next one."""
154
+
155
+ def __init__(self, width, height, fill=" "):
156
+ self.width = width
157
+ self.height = height
158
+ self.chars = [[fill] * width for _ in range(height)]
159
+ self.styles = [[""] * width for _ in range(height)]
160
+
161
+ def put(self, x, y, char, style=""):
162
+ """Set one cell. Anything outside the canvas is ignored.
163
+ Returns how many columns the character took."""
164
+ width = char_width(char)
165
+ if width == 0 or not (0 <= x < self.width and 0 <= y < self.height):
166
+ return width
167
+ if width == 2 and x + 1 >= self.width:
168
+ char, width = " ", 1 # no room for the second half
169
+ self._clear_wide(x, y)
170
+ self.chars[y][x] = char
171
+ self.styles[y][x] = style
172
+ if width == 2:
173
+ self._clear_wide(x + 1, y)
174
+ self.chars[y][x + 1] = ""
175
+ self.styles[y][x + 1] = style
176
+ return width
177
+
178
+ def _clear_wide(self, x, y):
179
+ """Before overwriting a cell, blank out the other half of any wide character in it."""
180
+ row = self.chars[y]
181
+ if row[x] == "" and x > 0:
182
+ row[x - 1] = " "
183
+ elif x + 1 < self.width and row[x + 1] == "":
184
+ row[x + 1] = " "
185
+
186
+ def text(self, x, y, text, style=""):
187
+ """Write a string starting at (x, y), clipped at the canvas edge.
188
+ Returns the x just after the text."""
189
+ for char in text:
190
+ x += self.put(x, y, char, style)
191
+ return x
192
+
193
+ def fill(self, x=0, y=0, w=None, h=None, char=" ", style=""):
194
+ """Fill a rectangle (the whole canvas by default)."""
195
+ w = self.width if w is None else w
196
+ h = self.height if h is None else h
197
+ for row in range(y, y + h):
198
+ for col in range(x, x + w):
199
+ self.put(col, row, char, style)
200
+
201
+ def border(self, x=0, y=0, w=None, h=None, kind="single", style="", title=None):
202
+ """Draw a box outline (around the whole canvas by default)."""
203
+ w = self.width if w is None else w
204
+ h = self.height if h is None else h
205
+ if w < 2 or h < 2:
206
+ return
207
+ tl, tr, bl, br, hz, vt = BORDERS[kind]
208
+ right, bottom = x + w - 1, y + h - 1
209
+ for col in range(x + 1, right):
210
+ self.put(col, y, hz, style)
211
+ self.put(col, bottom, hz, style)
212
+ for row in range(y + 1, bottom):
213
+ self.put(x, row, vt, style)
214
+ self.put(right, row, vt, style)
215
+ self.put(x, y, tl, style)
216
+ self.put(right, y, tr, style)
217
+ self.put(x, bottom, bl, style)
218
+ self.put(right, bottom, br, style)
219
+ if title and w > 4:
220
+ self.text(x + 2, y, fit(f" {title} ", w - 4), style)
221
+
222
+ def copy(self):
223
+ c = Canvas(0, 0)
224
+ c.width, c.height = self.width, self.height
225
+ c.chars = [row[:] for row in self.chars]
226
+ c.styles = [row[:] for row in self.styles]
227
+ return c
228
+
229
+ def blit(self, other, x, y):
230
+ """Copy another canvas onto this one with its top-left at (x, y), clipped."""
231
+ for row in range(other.height):
232
+ ty = y + row
233
+ if not 0 <= ty < self.height:
234
+ continue
235
+ for col in range(other.width):
236
+ tx = x + col
237
+ if 0 <= tx < self.width:
238
+ self.chars[ty][tx] = other.chars[row][col]
239
+ self.styles[ty][tx] = other.styles[row][col]
240
+ # Don't leave half a wide character at the clipped edges
241
+ if 0 <= x < self.width and self.chars[ty][x] == "":
242
+ self.chars[ty][x] = " "
243
+ end = x + other.width
244
+ if 0 <= end < self.width and self.chars[ty][end] == "" and self.chars[ty][end - 1] != "" \
245
+ and char_width(self.chars[ty][end - 1]) != 2:
246
+ self.chars[ty][end] = " "
247
+
248
+ def _run(self, row, start, end):
249
+ """Escape string for cells start..end of a row, style codes only where they change."""
250
+ out = []
251
+ current = ""
252
+ for col in range(start, end + 1):
253
+ char = self.chars[row][col]
254
+ if char == "":
255
+ continue # second half of a wide character
256
+ s = self.styles[row][col]
257
+ if s != current:
258
+ out.append(RESET + s)
259
+ current = s
260
+ out.append(char)
261
+ if current:
262
+ out.append(RESET)
263
+ return "".join(out)
264
+
265
+ def render(self, x, y):
266
+ """Build the escape string that draws this canvas with its top-left at
267
+ (x, y): one cursor move per line, style codes only where they change."""
268
+ return "".join(move(x, y + row) + self._run(row, 0, self.width - 1) for row in range(self.height))
269
+
270
+ def diff(self, old):
271
+ """Escape string that turns old (what's on screen) into this canvas,
272
+ touching only the cells that changed. old=None draws everything."""
273
+ if old is None or (old.width, old.height) != (self.width, self.height):
274
+ return self.render(0, 0)
275
+ out = []
276
+ for y in range(self.height):
277
+ new_chars, new_styles = self.chars[y], self.styles[y]
278
+ old_chars, old_styles = old.chars[y], old.styles[y]
279
+ x = 0
280
+ while x < self.width:
281
+ if new_chars[x] == old_chars[x] and new_styles[x] == old_styles[x]:
282
+ x += 1
283
+ continue
284
+ start = x - 1 if new_chars[x] == "" and x > 0 else x
285
+ end, gap = x, 0
286
+ x += 1
287
+ # Keep going through small unchanged gaps, cheaper than another cursor move
288
+ while x < self.width and gap <= 3:
289
+ if new_chars[x] == old_chars[x] and new_styles[x] == old_styles[x]:
290
+ gap += 1
291
+ else:
292
+ gap, end = 0, x
293
+ x += 1
294
+ if end + 1 < self.width and new_chars[end + 1] == "":
295
+ end += 1
296
+ out.append(move(start, y) + self._run(y, start, end))
297
+ x = end + 1
298
+ return "".join(out)
299
+
300
+ def draw_to(self, x, y):
301
+ """Draw the canvas to the terminal in a single write."""
302
+ write(render_frame(self.render(x, y)))
303
+
304
+ def render_frame(content):
305
+ """Wrap output in synchronized-update codes so terminals that support it
306
+ show the whole frame at once (others ignore the codes)."""
307
+ return ESC + "?2026h" + content + ESC + "?2026l"