codehs-utils 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.
- codehs_utils/__init__.py +78 -0
- codehs_utils/_buffer.py +51 -0
- codehs_utils/_pixelbuf.py +10 -0
- codehs_utils/_platform.py +156 -0
- codehs_utils/colors.py +510 -0
- codehs_utils/drawing.py +337 -0
- codehs_utils/geometry.py +70 -0
- codehs_utils/terminal.py +573 -0
- codehs_utils/text.py +199 -0
- codehs_utils-1.0.0.dist-info/METADATA +510 -0
- codehs_utils-1.0.0.dist-info/RECORD +13 -0
- codehs_utils-1.0.0.dist-info/WHEEL +4 -0
- codehs_utils-1.0.0.dist-info/licenses/LICENSE +21 -0
codehs_utils/drawing.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Higher-level drawing built on top of `.terminal` and `.colors`: text
|
|
2
|
+
banners/boxes, filled rectangles, a half-block pixel canvas, and clickable
|
|
3
|
+
buttons.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Callable, List, Optional, Sequence, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from .text import _visible_len, _char_width, align_text
|
|
9
|
+
from .colors import ColorLike, ColorText, GradientText, StyledText, ColorSpec, GradientColors
|
|
10
|
+
from .geometry import Rect
|
|
11
|
+
from .terminal import print_at, get_terminal_size
|
|
12
|
+
from ._pixelbuf import _pixel_buf
|
|
13
|
+
|
|
14
|
+
def _calculate_box_width(lines, padding=4):
|
|
15
|
+
if isinstance(lines, list):
|
|
16
|
+
return max(map(_visible_len, lines)) + padding
|
|
17
|
+
if isinstance(lines, (StyledText, ColorText, GradientText)):
|
|
18
|
+
return max(_visible_len(line) for line in str(lines).split("\n")) + padding
|
|
19
|
+
if isinstance(lines, str):
|
|
20
|
+
return max(_visible_len(line) for line in lines.split("\n")) + padding
|
|
21
|
+
return 48
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_RESET = "\033[0m"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Banner:
|
|
28
|
+
def __init__(self, lines: Union[str, Sequence[str]]):
|
|
29
|
+
if isinstance(lines, str):
|
|
30
|
+
lines = lines.split("\n")
|
|
31
|
+
lines = [str(line) for line in lines]
|
|
32
|
+
self.width = max((_visible_len(line) for line in lines), default=0)
|
|
33
|
+
self.lines = [line + " " * (self.width - _visible_len(line)) for line in lines]
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def height(self) -> int:
|
|
37
|
+
return len(self.lines)
|
|
38
|
+
|
|
39
|
+
def draw(self, row: int, col: int) -> Rect:
|
|
40
|
+
return print_at(row, col, self)
|
|
41
|
+
|
|
42
|
+
def align(self, width: int, align: str = "left", fillchar: str = " ") -> str:
|
|
43
|
+
return BannerRow([self]).align(width, align, fillchar)
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
return "\n".join(self.lines)
|
|
47
|
+
|
|
48
|
+
def __repr__(self):
|
|
49
|
+
return f"Banner(width={self.width}, height={self.height})"
|
|
50
|
+
|
|
51
|
+
def __add__(self, other):
|
|
52
|
+
if isinstance(other, BannerRow):
|
|
53
|
+
return BannerRow([self] + other.banners, other.gap, other.valign)
|
|
54
|
+
if isinstance(other, Banner):
|
|
55
|
+
return BannerRow([self, other])
|
|
56
|
+
if isinstance(other, str):
|
|
57
|
+
return str(self) + other
|
|
58
|
+
return NotImplemented
|
|
59
|
+
|
|
60
|
+
def __radd__(self, other):
|
|
61
|
+
if isinstance(other, int) and other == 0:
|
|
62
|
+
return self
|
|
63
|
+
if isinstance(other, str):
|
|
64
|
+
return other + str(self)
|
|
65
|
+
return NotImplemented
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BannerRow:
|
|
69
|
+
_VALIGNS = ("top", "middle", "bottom")
|
|
70
|
+
|
|
71
|
+
def __init__(self, banners: Sequence[Banner], gap: int = 1, valign: str = "top"):
|
|
72
|
+
self.banners: List[Banner] = list(banners)
|
|
73
|
+
self.set_gap(gap)
|
|
74
|
+
self.set_valign(valign)
|
|
75
|
+
|
|
76
|
+
def set_gap(self, gap: int):
|
|
77
|
+
self.gap = max(0, gap)
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def set_valign(self, valign: str):
|
|
81
|
+
if valign not in self._VALIGNS:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Unknown valign mode: '{valign}'. Use 'top', 'middle', or 'bottom'."
|
|
84
|
+
)
|
|
85
|
+
self.valign = valign
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def width(self) -> int:
|
|
90
|
+
return sum(b.width for b in self.banners) + self.gap * max(0, len(self.banners) - 1)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def height(self) -> int:
|
|
94
|
+
return max((b.height for b in self.banners), default=0)
|
|
95
|
+
|
|
96
|
+
def draw(self, row: int, col: int) -> Rect:
|
|
97
|
+
return print_at(row, col, self)
|
|
98
|
+
|
|
99
|
+
def _column(self, b: Banner, height: int) -> List[str]:
|
|
100
|
+
extra = height - b.height
|
|
101
|
+
above = {"top": 0, "bottom": extra, "middle": extra // 2}[self.valign]
|
|
102
|
+
blank = " " * b.width
|
|
103
|
+
lines = [blank] * above + b.lines + [blank] * (extra - above)
|
|
104
|
+
return [ln + _RESET if "\033" in ln else ln for ln in lines]
|
|
105
|
+
|
|
106
|
+
def _render(self, gaps: List[int], fillchar: str = " ") -> str:
|
|
107
|
+
height = self.height
|
|
108
|
+
cols = [self._column(b, height) for b in self.banners]
|
|
109
|
+
rows = []
|
|
110
|
+
for r in range(height):
|
|
111
|
+
pieces = []
|
|
112
|
+
for i, col in enumerate(cols):
|
|
113
|
+
pieces.append(col[r])
|
|
114
|
+
if i < len(gaps):
|
|
115
|
+
pieces.append(fillchar * gaps[i])
|
|
116
|
+
rows.append("".join(pieces))
|
|
117
|
+
return "\n".join(rows)
|
|
118
|
+
|
|
119
|
+
def align(self, width: int, align: str = "left", fillchar: str = " ") -> str:
|
|
120
|
+
if not self.banners:
|
|
121
|
+
return ""
|
|
122
|
+
gaps = [self.gap] * (len(self.banners) - 1)
|
|
123
|
+
pad = max(0, width - self.width)
|
|
124
|
+
left = right = 0
|
|
125
|
+
|
|
126
|
+
if align == "left":
|
|
127
|
+
right = pad
|
|
128
|
+
elif align == "right":
|
|
129
|
+
left = pad
|
|
130
|
+
elif align == "center":
|
|
131
|
+
left = pad // 2
|
|
132
|
+
right = pad - left
|
|
133
|
+
elif align == "justify":
|
|
134
|
+
if gaps:
|
|
135
|
+
base, extra = divmod(pad, len(gaps))
|
|
136
|
+
gaps = [g + base + (1 if i < extra else 0) for i, g in enumerate(gaps)]
|
|
137
|
+
else:
|
|
138
|
+
right = pad
|
|
139
|
+
else:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Unknown align mode: '{align}'. Use 'left', 'right', 'center', or 'justify'."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
body = self._render(gaps, fillchar)
|
|
145
|
+
return "\n".join(fillchar * left + line + fillchar * right for line in body.split("\n"))
|
|
146
|
+
|
|
147
|
+
def __str__(self) -> str:
|
|
148
|
+
return self._render([self.gap] * max(0, len(self.banners) - 1))
|
|
149
|
+
|
|
150
|
+
def __repr__(self):
|
|
151
|
+
return f"BannerRow({len(self.banners)} banners, width={self.width}, height={self.height})"
|
|
152
|
+
|
|
153
|
+
def __add__(self, other):
|
|
154
|
+
if isinstance(other, Banner):
|
|
155
|
+
return BannerRow(self.banners + [other], self.gap, self.valign)
|
|
156
|
+
if isinstance(other, BannerRow):
|
|
157
|
+
return BannerRow(self.banners + other.banners, self.gap, self.valign)
|
|
158
|
+
if isinstance(other, str):
|
|
159
|
+
return str(self) + other
|
|
160
|
+
return NotImplemented
|
|
161
|
+
|
|
162
|
+
def __radd__(self, other):
|
|
163
|
+
if isinstance(other, int) and other == 0:
|
|
164
|
+
return self
|
|
165
|
+
if isinstance(other, str):
|
|
166
|
+
return other + str(self)
|
|
167
|
+
return NotImplemented
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def banner(
|
|
171
|
+
text: str,
|
|
172
|
+
width: Union[int, None] = None,
|
|
173
|
+
color: Union[ColorLike, str, tuple, None] = None,
|
|
174
|
+
colors: Optional[GradientColors] = None,
|
|
175
|
+
background: Union[ColorLike, str, tuple, None] = None,
|
|
176
|
+
background_colors: Optional[GradientColors] = None,
|
|
177
|
+
styles: Optional[Sequence[str]] = None,
|
|
178
|
+
padding: int = 1,
|
|
179
|
+
align: str = "center",
|
|
180
|
+
) -> Banner:
|
|
181
|
+
if isinstance(text, (list, tuple)):
|
|
182
|
+
text = "\n".join(str(t) for t in text)
|
|
183
|
+
else:
|
|
184
|
+
text = str(text)
|
|
185
|
+
if width is None:
|
|
186
|
+
width = _calculate_box_width(text)
|
|
187
|
+
padding = max(0, padding)
|
|
188
|
+
line = align_text(text, width, align=align)
|
|
189
|
+
blank = " " * width
|
|
190
|
+
|
|
191
|
+
def _style(s: str) -> str:
|
|
192
|
+
gt = GradientText(s)
|
|
193
|
+
if colors is not None:
|
|
194
|
+
gt.set_colors(colors)
|
|
195
|
+
elif color is not None:
|
|
196
|
+
gt.set_color(color)
|
|
197
|
+
if background is not None:
|
|
198
|
+
gt.set_background(background)
|
|
199
|
+
if background_colors is not None:
|
|
200
|
+
gt.set_background_gradient(background_colors)
|
|
201
|
+
if styles:
|
|
202
|
+
gt.set_styles(*styles)
|
|
203
|
+
return str(gt)
|
|
204
|
+
|
|
205
|
+
rows = [_style(blank)] * padding + [_style(line)] + [_style(blank)] * padding
|
|
206
|
+
return Banner("\n".join(rows))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_BORDER_STYLES = {
|
|
210
|
+
"single": "\u250c\u2510\u2514\u2518\u2500\u2502",
|
|
211
|
+
"double": "\u2554\u2557\u255a\u255d\u2550\u2551",
|
|
212
|
+
"rounded": "\u256d\u256e\u2570\u256f\u2500\u2502",
|
|
213
|
+
"heavy": "\u250f\u2513\u2517\u251b\u2501\u2503",
|
|
214
|
+
"ascii": "++++-|",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def fill_rect(row: int, col: int, width: int, height: int, color=None,
|
|
219
|
+
char: str = " ", foreground=None) -> Rect:
|
|
220
|
+
char = str(char)
|
|
221
|
+
if len(char) != 1 or _char_width(char) != 1:
|
|
222
|
+
raise ValueError("char must be a single character, one column wide.")
|
|
223
|
+
width = max(0, int(width))
|
|
224
|
+
height = max(0, int(height))
|
|
225
|
+
if width and height:
|
|
226
|
+
line = str(ColorText(char * width, foreground, color))
|
|
227
|
+
print_at(row, col, "\n".join([line] * height))
|
|
228
|
+
return Rect(row, col, width, height)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def get_pixel_size() -> Tuple[int, int]:
|
|
232
|
+
width, height = get_terminal_size()
|
|
233
|
+
return width, height * 2
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _draw_pixel_cell(x: int, row: int):
|
|
237
|
+
top = _pixel_buf.get((x, 2 * row - 1))
|
|
238
|
+
bottom = _pixel_buf.get((x, 2 * row))
|
|
239
|
+
if top and bottom:
|
|
240
|
+
glyph = ColorText("\u2584", bottom, top)
|
|
241
|
+
elif top:
|
|
242
|
+
glyph = ColorText("\u2580", top)
|
|
243
|
+
elif bottom:
|
|
244
|
+
glyph = ColorText("\u2584", bottom)
|
|
245
|
+
else:
|
|
246
|
+
glyph = " "
|
|
247
|
+
print_at(row, x, glyph)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def set_pixel(x: int, y: int, color):
|
|
251
|
+
x, y = int(x), int(y)
|
|
252
|
+
rgb = ColorLike(color).rgb
|
|
253
|
+
if x < 1 or y < 1:
|
|
254
|
+
return
|
|
255
|
+
if rgb is None:
|
|
256
|
+
_pixel_buf.pop((x, y), None)
|
|
257
|
+
else:
|
|
258
|
+
_pixel_buf[(x, y)] = rgb
|
|
259
|
+
_draw_pixel_cell(x, (y + 1) // 2)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def clear_pixel(x: int, y: int):
|
|
263
|
+
set_pixel(x, y, None)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def clear_pixels():
|
|
267
|
+
_pixel_buf.clear()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Button:
|
|
271
|
+
def __init__(
|
|
272
|
+
self,
|
|
273
|
+
text: str,
|
|
274
|
+
row: int,
|
|
275
|
+
col: int,
|
|
276
|
+
width: Optional[int] = None,
|
|
277
|
+
background: Union[ColorLike, str, tuple] = "skyblue",
|
|
278
|
+
color: Union[ColorLike, str, tuple, None] = None,
|
|
279
|
+
*,
|
|
280
|
+
hover_background=None,
|
|
281
|
+
hover_color=None,
|
|
282
|
+
pressed_background=None,
|
|
283
|
+
pressed_color=None,
|
|
284
|
+
padding: int = 1,
|
|
285
|
+
styles: Optional[Sequence[str]] = None,
|
|
286
|
+
trigger: str = "release",
|
|
287
|
+
on_click: Optional[Callable[[], None]] = None,
|
|
288
|
+
):
|
|
289
|
+
if trigger not in ("release", "press"):
|
|
290
|
+
raise ValueError("trigger must be 'release' or 'press'.")
|
|
291
|
+
bg = ColorLike(background)
|
|
292
|
+
fg = ColorLike(color) if color is not None else bg.contrast()
|
|
293
|
+
hbg = ColorLike(hover_background) if hover_background is not None else bg.darken(0.25)
|
|
294
|
+
hfg = ColorLike(hover_color) if hover_color is not None else hbg.contrast()
|
|
295
|
+
pbg = ColorLike(pressed_background) if pressed_background is not None else bg.darken(0.55)
|
|
296
|
+
pfg = ColorLike(pressed_color) if pressed_color is not None else pbg.contrast()
|
|
297
|
+
self._colors = {"idle": (fg, bg), "hover": (hfg, hbg), "pressed": (pfg, pbg)}
|
|
298
|
+
self.text = str(text)
|
|
299
|
+
self.row = row
|
|
300
|
+
self.col = col
|
|
301
|
+
self.padding = padding
|
|
302
|
+
self.styles = styles
|
|
303
|
+
self.trigger = trigger
|
|
304
|
+
self.on_click = on_click
|
|
305
|
+
self._width = width
|
|
306
|
+
self._armed = False
|
|
307
|
+
self.state = "idle"
|
|
308
|
+
first = self._banner("idle")
|
|
309
|
+
self.rect = Rect(row, col, first.width, first.height)
|
|
310
|
+
|
|
311
|
+
def _banner(self, state: str) -> Banner:
|
|
312
|
+
fg, bg = self._colors[state]
|
|
313
|
+
return banner(self.text, width=self._width, color=fg, background=bg,
|
|
314
|
+
styles=self.styles, padding=self.padding)
|
|
315
|
+
|
|
316
|
+
def draw(self) -> Rect:
|
|
317
|
+
self.rect = self._banner(self.state).draw(self.row, self.col)
|
|
318
|
+
return self.rect
|
|
319
|
+
|
|
320
|
+
def handle(self, event) -> bool:
|
|
321
|
+
if getattr(event, "kind", None) != "mouse" or event.type.startswith("wheel"):
|
|
322
|
+
return False
|
|
323
|
+
inside = self.rect.contains(event.x, event.y)
|
|
324
|
+
clicked = False
|
|
325
|
+
if event.type == "press" and event.button == "left" and inside:
|
|
326
|
+
self._armed = True
|
|
327
|
+
clicked = self.trigger == "press"
|
|
328
|
+
elif event.type == "release" and self._armed:
|
|
329
|
+
clicked = inside and self.trigger == "release"
|
|
330
|
+
self._armed = False
|
|
331
|
+
new_state = "pressed" if (self._armed and inside) else ("hover" if inside else "idle")
|
|
332
|
+
if new_state != self.state:
|
|
333
|
+
self.state = new_state
|
|
334
|
+
self.draw()
|
|
335
|
+
if clicked and self.on_click is not None:
|
|
336
|
+
self.on_click()
|
|
337
|
+
return clicked
|
codehs_utils/geometry.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The `Rect` type: a plain (row, col, width, height) rectangle returned by
|
|
2
|
+
drawing functions, with `.fill()` and `.draw_border()` convenience methods.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from ._buffer import frame
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Rect(NamedTuple):
|
|
11
|
+
row: int
|
|
12
|
+
col: int
|
|
13
|
+
width: int
|
|
14
|
+
height: int
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def top(self) -> int:
|
|
18
|
+
return self.row
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def left(self) -> int:
|
|
22
|
+
return self.col
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def bottom(self) -> int:
|
|
26
|
+
return self.row + self.height
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def right(self) -> int:
|
|
30
|
+
return self.col + self.width
|
|
31
|
+
|
|
32
|
+
def contains(self, x: int, y: int) -> bool:
|
|
33
|
+
return self.col <= x < self.col + self.width and self.row <= y < self.row + self.height
|
|
34
|
+
|
|
35
|
+
def fill(self, color=None, char: str = " "):
|
|
36
|
+
# Imported lazily to avoid a circular import: .drawing imports Rect
|
|
37
|
+
# from this module at load time, so this module can't import
|
|
38
|
+
# .drawing back at load time too.
|
|
39
|
+
from .drawing import fill_rect
|
|
40
|
+
fill_rect(self.row, self.col, self.width, self.height, color, char)
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def draw_border(self, color=None, style: str = "single", background=None):
|
|
44
|
+
# Imported lazily for the same reason as in .fill() above: .colors,
|
|
45
|
+
# .terminal, and .drawing all end up depending on this module.
|
|
46
|
+
from .colors import ColorText
|
|
47
|
+
from .terminal import print_at
|
|
48
|
+
from .drawing import _BORDER_STYLES
|
|
49
|
+
|
|
50
|
+
if style not in _BORDER_STYLES:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"Unknown border style: '{style}'. Available styles: "
|
|
53
|
+
f"{', '.join(sorted(_BORDER_STYLES))}"
|
|
54
|
+
)
|
|
55
|
+
if self.width < 2 or self.height < 2:
|
|
56
|
+
raise ValueError("A border needs a Rect at least 2 wide and 2 tall.")
|
|
57
|
+
tl, tr, bl, br, h, v = _BORDER_STYLES[style]
|
|
58
|
+
w = self.width
|
|
59
|
+
|
|
60
|
+
def paint(text):
|
|
61
|
+
return str(ColorText(text, color, background))
|
|
62
|
+
|
|
63
|
+
with frame():
|
|
64
|
+
print_at(self.row, self.col, paint(tl + h * (w - 2) + tr))
|
|
65
|
+
print_at(self.bottom - 1, self.col, paint(bl + h * (w - 2) + br))
|
|
66
|
+
if self.height > 2:
|
|
67
|
+
side = "\n".join([paint(v)] * (self.height - 2))
|
|
68
|
+
print_at(self.row + 1, self.col, side)
|
|
69
|
+
print_at(self.row + 1, self.right - 1, side)
|
|
70
|
+
return self
|