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.
@@ -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
@@ -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