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/__init__.py +14 -0
- cmdgui/_platform.py +126 -0
- cmdgui/inputs.py +214 -0
- cmdgui/layout.py +249 -0
- cmdgui/shorts.py +307 -0
- cmdgui/view.py +713 -0
- cmdgui/widgets.py +577 -0
- cmdgui-0.1.0.dist-info/METADATA +263 -0
- cmdgui-0.1.0.dist-info/RECORD +11 -0
- cmdgui-0.1.0.dist-info/WHEEL +5 -0
- cmdgui-0.1.0.dist-info/top_level.txt +1 -0
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"
|