libtodraw 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.
- libtodraw/__init__.py +58 -0
- libtodraw/canvas.py +530 -0
- libtodraw/capture.py +37 -0
- libtodraw/color.py +142 -0
- libtodraw/colorsystem.py +87 -0
- libtodraw/console.py +471 -0
- libtodraw/export.py +143 -0
- libtodraw/highlighter.py +66 -0
- libtodraw/inspector.py +142 -0
- libtodraw/jupyter.py +21 -0
- libtodraw/live.py +201 -0
- libtodraw/markdown_render.py +166 -0
- libtodraw/markup.py +166 -0
- libtodraw/measure.py +35 -0
- libtodraw/options.py +46 -0
- libtodraw/renderables/__init__.py +24 -0
- libtodraw/renderables/columns.py +82 -0
- libtodraw/renderables/layout.py +188 -0
- libtodraw/renderables/panel.py +229 -0
- libtodraw/renderables/progress.py +265 -0
- libtodraw/renderables/rule.py +58 -0
- libtodraw/renderables/spinner.py +113 -0
- libtodraw/renderables/table.py +382 -0
- libtodraw/renderables/tree.py +121 -0
- libtodraw/segment.py +40 -0
- libtodraw/status.py +105 -0
- libtodraw/style.py +251 -0
- libtodraw/syntax.py +442 -0
- libtodraw/terminal.py +71 -0
- libtodraw/text.py +315 -0
- libtodraw/theme.py +56 -0
- libtodraw/traceback_handler.py +122 -0
- libtodraw-1.0.0.dist-info/METADATA +888 -0
- libtodraw-1.0.0.dist-info/RECORD +36 -0
- libtodraw-1.0.0.dist-info/WHEEL +4 -0
- libtodraw-1.0.0.dist-info/licenses/LICENSE +21 -0
libtodraw/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""libtodraw — Rich on steroids. Full-featured terminal rendering library."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from .color import Color
|
|
6
|
+
from .style import Style, StyleType
|
|
7
|
+
from .text import Text
|
|
8
|
+
from .segment import Segment
|
|
9
|
+
from .measure import Measurement
|
|
10
|
+
from .options import ConsoleOptions, JustifyMethod
|
|
11
|
+
from .console import Console
|
|
12
|
+
from .live import Live
|
|
13
|
+
from .status import Status
|
|
14
|
+
from .capture import Capture
|
|
15
|
+
from .syntax import Syntax, SyntaxTheme
|
|
16
|
+
from .markdown_render import Markdown
|
|
17
|
+
from .inspector import inspect as pretty_inspect
|
|
18
|
+
from .traceback_handler import install as install_traceback
|
|
19
|
+
from .highlighter import Highlighter
|
|
20
|
+
from .theme import Theme, DEFAULT_THEME
|
|
21
|
+
from .markup import parse as parse_markup
|
|
22
|
+
from .terminal import get_size, detect_truecolor, detect_unicode
|
|
23
|
+
from .export import export_text, export_html
|
|
24
|
+
|
|
25
|
+
from .renderables.table import Table, Column
|
|
26
|
+
from .renderables.panel import Panel, Borders
|
|
27
|
+
from .renderables.tree import Tree, TreeNode
|
|
28
|
+
from .renderables.columns import Columns
|
|
29
|
+
from .renderables.layout import Layout
|
|
30
|
+
from .renderables.rule import Rule
|
|
31
|
+
from .renderables.progress import Progress, TaskID, BarColumn, SpinnerColumn, TextColumn, TimeRemainingColumn
|
|
32
|
+
from .renderables.spinner import Spinner
|
|
33
|
+
from .canvas import Canvas, Cell, CanvasRegion
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
print = console.print
|
|
38
|
+
log = console.log
|
|
39
|
+
input = console.input
|
|
40
|
+
clear = console.clear
|
|
41
|
+
rule = console.rule
|
|
42
|
+
status = console.status
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Console", "ConsoleOptions", "Measurement", "Capture", "Status",
|
|
46
|
+
"Color", "Style", "Text", "Segment",
|
|
47
|
+
"Table", "Column", "Panel", "Borders",
|
|
48
|
+
"Tree", "TreeNode", "Columns", "Layout", "Rule",
|
|
49
|
+
"Progress", "TaskID", "BarColumn", "SpinnerColumn", "TextColumn", "TimeRemainingColumn",
|
|
50
|
+
"Spinner", "Live",
|
|
51
|
+
"Canvas", "Cell", "CanvasRegion",
|
|
52
|
+
"Markdown", "Syntax", "SyntaxTheme",
|
|
53
|
+
"Theme", "DEFAULT_THEME", "Highlighter",
|
|
54
|
+
"parse_markup", "get_size", "detect_truecolor", "detect_unicode",
|
|
55
|
+
"export_text", "export_html",
|
|
56
|
+
"pretty_inspect", "install_traceback",
|
|
57
|
+
"console", "print", "log", "input", "clear", "rule", "status",
|
|
58
|
+
]
|
libtodraw/canvas.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Canvas — pixel-level control over every character in the terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
from typing import Optional, Tuple, List
|
|
8
|
+
|
|
9
|
+
from .style import Style
|
|
10
|
+
from .color import Color
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _visible_len(s: str) -> int:
|
|
14
|
+
return len(re.sub(r'\033\[[0-9;]*m', '', s))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cell:
|
|
18
|
+
"""A single terminal cell with full style control."""
|
|
19
|
+
|
|
20
|
+
__slots__ = ("char", "style", "z_index")
|
|
21
|
+
|
|
22
|
+
def __init__(self, char: str = " ", style: Optional[Style] = None, z_index: int = 0):
|
|
23
|
+
self.char = char
|
|
24
|
+
self.style = style
|
|
25
|
+
self.z_index = z_index
|
|
26
|
+
|
|
27
|
+
def __repr__(self):
|
|
28
|
+
return f"Cell({self.char!r}, {self.style!r})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Canvas:
|
|
32
|
+
"""Full character-level terminal buffer. Control every cell."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, width: int = 80, height: int = 24):
|
|
35
|
+
self.width = width
|
|
36
|
+
self.height = height
|
|
37
|
+
self._cells: List[List[Cell]] = [
|
|
38
|
+
[Cell() for _ in range(width)] for _ in range(height)
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def _in_bounds(self, x: int, y: int) -> bool:
|
|
42
|
+
return 0 <= x < self.width and 0 <= y < self.height
|
|
43
|
+
|
|
44
|
+
# ── Pixel-level control ──
|
|
45
|
+
|
|
46
|
+
def set(self, x: int, y: int, char: str, style: Optional[Style] = None) -> Canvas:
|
|
47
|
+
"""Set a single cell. Returns self for chaining."""
|
|
48
|
+
if self._in_bounds(x, y):
|
|
49
|
+
self._cells[y][x].char = char
|
|
50
|
+
self._cells[y][x].style = style
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def get(self, x: int, y: int) -> Cell:
|
|
54
|
+
"""Get a single cell."""
|
|
55
|
+
if self._in_bounds(x, y):
|
|
56
|
+
return self._cells[y][x]
|
|
57
|
+
return Cell()
|
|
58
|
+
|
|
59
|
+
def set_style(self, x: int, y: int, style: Style) -> Canvas:
|
|
60
|
+
"""Set style on a cell without changing its character."""
|
|
61
|
+
if self._in_bounds(x, y):
|
|
62
|
+
self._cells[y][x].style = style
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def set_char(self, x: int, y: int, char: str) -> Canvas:
|
|
66
|
+
"""Set a character without changing its style."""
|
|
67
|
+
if self._in_bounds(x, y):
|
|
68
|
+
self._cells[y][x].char = char
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def clear_cell(self, x: int, y: int) -> Canvas:
|
|
72
|
+
"""Reset a cell to blank with no style."""
|
|
73
|
+
if self._in_bounds(x, y):
|
|
74
|
+
self._cells[y][x] = Cell()
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
# ── Bulk operations ──
|
|
78
|
+
|
|
79
|
+
def fill(self, x: int, y: int, w: int, h: int, char: str = " ",
|
|
80
|
+
style: Optional[Style] = None) -> Canvas:
|
|
81
|
+
"""Fill a rectangular region."""
|
|
82
|
+
for dy in range(h):
|
|
83
|
+
for dx in range(w):
|
|
84
|
+
self.set(x + dx, y + dy, char, style)
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def clear(self, x: int = 0, y: int = 0, w: Optional[int] = None,
|
|
88
|
+
h: Optional[int] = None) -> Canvas:
|
|
89
|
+
"""Clear a region (or entire canvas if no args)."""
|
|
90
|
+
w = w or self.width
|
|
91
|
+
h = h or self.height
|
|
92
|
+
for dy in range(h):
|
|
93
|
+
for dx in range(w):
|
|
94
|
+
if self._in_bounds(x + dx, y + dy):
|
|
95
|
+
self._cells[y + dy][x + dx] = Cell()
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def clear_all(self) -> Canvas:
|
|
99
|
+
"""Clear entire canvas."""
|
|
100
|
+
return self.clear(0, 0, self.width, self.height)
|
|
101
|
+
|
|
102
|
+
# ── Drawing primitives ──
|
|
103
|
+
|
|
104
|
+
def draw_rect(self, x: int, y: int, w: int, h: int,
|
|
105
|
+
border: Optional[Style] = None, fill: Optional[Style] = None,
|
|
106
|
+
chars: Optional[dict] = None) -> Canvas:
|
|
107
|
+
"""Draw a rectangle with optional border and fill.
|
|
108
|
+
|
|
109
|
+
chars: override border characters.
|
|
110
|
+
keys: tl, tr, bl, br, hor, ver, fill
|
|
111
|
+
"""
|
|
112
|
+
c = chars or {}
|
|
113
|
+
tl = c.get("tl", "┌")
|
|
114
|
+
tr = c.get("tr", "┐")
|
|
115
|
+
bl = c.get("bl", "└")
|
|
116
|
+
br = c.get("br", "┘")
|
|
117
|
+
hor = c.get("hor", "─")
|
|
118
|
+
ver = c.get("ver", "│")
|
|
119
|
+
fill_ch = c.get("fill", " ")
|
|
120
|
+
|
|
121
|
+
# Corners
|
|
122
|
+
self.set(x, y, tl, border)
|
|
123
|
+
self.set(x + w - 1, y, tr, border)
|
|
124
|
+
self.set(x, y + h - 1, bl, border)
|
|
125
|
+
self.set(x + w - 1, y + h - 1, br, border)
|
|
126
|
+
|
|
127
|
+
# Horizontal edges
|
|
128
|
+
for i in range(1, w - 1):
|
|
129
|
+
self.set(x + i, y, hor, border)
|
|
130
|
+
self.set(x + i, y + h - 1, hor, border)
|
|
131
|
+
|
|
132
|
+
# Vertical edges
|
|
133
|
+
for i in range(1, h - 1):
|
|
134
|
+
self.set(x, y + i, ver, border)
|
|
135
|
+
self.set(x + w - 1, y + i, ver, border)
|
|
136
|
+
|
|
137
|
+
# Fill interior
|
|
138
|
+
if fill:
|
|
139
|
+
for dy in range(1, h - 1):
|
|
140
|
+
for dx in range(1, w - 1):
|
|
141
|
+
self.set(x + dx, y + dy, fill_ch, fill)
|
|
142
|
+
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def draw_hline(self, x: int, y: int, length: int, char: str = "─",
|
|
146
|
+
style: Optional[Style] = None) -> Canvas:
|
|
147
|
+
"""Draw a horizontal line."""
|
|
148
|
+
for i in range(length):
|
|
149
|
+
self.set(x + i, y, char, style)
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def draw_vline(self, x: int, y: int, length: int, char: str = "│",
|
|
153
|
+
style: Optional[Style] = None) -> Canvas:
|
|
154
|
+
"""Draw a vertical line."""
|
|
155
|
+
for i in range(length):
|
|
156
|
+
self.set(x, y + i, char, style)
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def draw_box(self, x: int, y: int, w: int, h: int,
|
|
160
|
+
style: Optional[Style] = None, box_type: str = "simple") -> Canvas:
|
|
161
|
+
"""Draw a box using predefined box types.
|
|
162
|
+
|
|
163
|
+
box_type: simple, rounded, heavy, double, dashed, none
|
|
164
|
+
"""
|
|
165
|
+
boxes = {
|
|
166
|
+
"simple": {"tl": "┌", "tr": "┐", "bl": "└", "br": "┘", "hor": "─", "ver": "│"},
|
|
167
|
+
"rounded": {"tl": "╭", "tr": "╮", "bl": "╰", "br": "╯", "hor": "─", "ver": "│"},
|
|
168
|
+
"heavy": {"tl": "┏", "tr": "┓", "bl": "┗", "br": "┛", "hor": "━", "ver": "┃"},
|
|
169
|
+
"double": {"tl": "╔", "tr": "╗", "bl": "╚", "br": "╝", "hor": "═", "ver": "║"},
|
|
170
|
+
"dashed": {"tl": "┌", "tr": "┐", "bl": "└", "br": "┘", "hor": "╌", "ver": "╎"},
|
|
171
|
+
"dotted": {"tl": "•", "tr": "•", "bl": "•", "br": "•", "hor": "·", "ver": "⋮"},
|
|
172
|
+
}
|
|
173
|
+
chars = boxes.get(box_type, boxes["simple"])
|
|
174
|
+
return self.draw_rect(x, y, w, h, border=style, chars=chars)
|
|
175
|
+
|
|
176
|
+
# ── Text placement ──
|
|
177
|
+
|
|
178
|
+
def put(self, x: int, y: int, text: str, style: Optional[Style] = None) -> Canvas:
|
|
179
|
+
"""Place text at position, character by character. Handles newlines."""
|
|
180
|
+
cx, cy = x, y
|
|
181
|
+
for ch in text:
|
|
182
|
+
if ch == "\n":
|
|
183
|
+
cx = x
|
|
184
|
+
cy += 1
|
|
185
|
+
continue
|
|
186
|
+
self.set(cx, cy, ch, style)
|
|
187
|
+
cx += 1
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def put_centered(self, y: int, text: str, style: Optional[Style] = None) -> Canvas:
|
|
191
|
+
"""Place text centered horizontally on row y."""
|
|
192
|
+
tw = _visible_len(text)
|
|
193
|
+
x = max(0, (self.width - tw) // 2)
|
|
194
|
+
return self.put(x, y, text, style)
|
|
195
|
+
|
|
196
|
+
def put_right(self, y: int, text: str, style: Optional[Style] = None) -> Canvas:
|
|
197
|
+
"""Place text right-aligned on row y."""
|
|
198
|
+
tw = _visible_len(text)
|
|
199
|
+
x = max(0, self.width - tw)
|
|
200
|
+
return self.put(x, y, text, style)
|
|
201
|
+
|
|
202
|
+
def put_at(self, x: int, y: int, text: str, style: Optional[Style] = None,
|
|
203
|
+
align: str = "left", max_width: Optional[int] = None) -> Canvas:
|
|
204
|
+
"""Place text with alignment control.
|
|
205
|
+
|
|
206
|
+
align: left, center, right
|
|
207
|
+
max_width: if set, pad/truncate to this width
|
|
208
|
+
"""
|
|
209
|
+
tw = _visible_len(text)
|
|
210
|
+
if max_width and tw > max_width:
|
|
211
|
+
text = text[:max_width]
|
|
212
|
+
tw = max_width
|
|
213
|
+
|
|
214
|
+
if align == "center":
|
|
215
|
+
pad = (max_width or self.width) // 2 - tw // 2
|
|
216
|
+
x = x + pad
|
|
217
|
+
elif align == "right":
|
|
218
|
+
pad = (max_width or self.width) - tw
|
|
219
|
+
x = x + max(0, pad)
|
|
220
|
+
|
|
221
|
+
return self.put(x, y, text, style)
|
|
222
|
+
|
|
223
|
+
def write_line(self, y: int, x: int, text: str, style: Optional[Style] = None,
|
|
224
|
+
justify: str = "left", width: Optional[int] = None) -> Canvas:
|
|
225
|
+
"""Write a full line with justification and padding.
|
|
226
|
+
|
|
227
|
+
justify: left, center, right, full
|
|
228
|
+
width: line width for justification
|
|
229
|
+
"""
|
|
230
|
+
w = width or self.width
|
|
231
|
+
tw = _visible_len(text)
|
|
232
|
+
|
|
233
|
+
if justify == "center":
|
|
234
|
+
pad = (w - tw) // 2
|
|
235
|
+
full = " " * pad + text + " " * (w - tw - pad)
|
|
236
|
+
elif justify == "right":
|
|
237
|
+
full = " " * (w - tw) + text
|
|
238
|
+
elif justify == "full":
|
|
239
|
+
words = text.split()
|
|
240
|
+
if len(words) <= 1:
|
|
241
|
+
full = text.ljust(w)
|
|
242
|
+
else:
|
|
243
|
+
total_chars = sum(len(w) for w in words)
|
|
244
|
+
total_spaces = w - total_chars
|
|
245
|
+
space_each = total_spaces // (len(words) - 1) if len(words) > 1 else 0
|
|
246
|
+
extra = total_spaces % (len(words) - 1) if len(words) > 1 else 0
|
|
247
|
+
parts = []
|
|
248
|
+
for i, word in enumerate(words):
|
|
249
|
+
parts.append(word)
|
|
250
|
+
if i < len(words) - 1:
|
|
251
|
+
spaces = space_each + (1 if i < extra else 0)
|
|
252
|
+
parts.append(" " * spaces)
|
|
253
|
+
full = "".join(parts)
|
|
254
|
+
else:
|
|
255
|
+
full = text.ljust(w)
|
|
256
|
+
|
|
257
|
+
return self.put(x, y, full, style)
|
|
258
|
+
|
|
259
|
+
# ── Gradient fill ──
|
|
260
|
+
|
|
261
|
+
def gradient_fill(self, x: int, y: int, w: int, h: int,
|
|
262
|
+
color_start: Color, color_end: Color,
|
|
263
|
+
char: str = "█", horizontal: bool = True) -> Canvas:
|
|
264
|
+
"""Fill a region with a color gradient."""
|
|
265
|
+
length = w if horizontal else h
|
|
266
|
+
for i in range(length):
|
|
267
|
+
t = i / max(length - 1, 1)
|
|
268
|
+
color = color_start.lerp(color_end, t)
|
|
269
|
+
style = Style(color=color)
|
|
270
|
+
if horizontal:
|
|
271
|
+
for dy in range(h):
|
|
272
|
+
self.set(x + i, y + dy, char, style)
|
|
273
|
+
else:
|
|
274
|
+
for dx in range(w):
|
|
275
|
+
self.set(x + dx, y + i, char, style)
|
|
276
|
+
return self
|
|
277
|
+
|
|
278
|
+
def gradient_border(self, x: int, y: int, w: int, h: int,
|
|
279
|
+
color_start: Color, color_end: Color,
|
|
280
|
+
box_type: str = "simple") -> Canvas:
|
|
281
|
+
"""Draw a box border with gradient colors along the perimeter."""
|
|
282
|
+
boxes = {
|
|
283
|
+
"simple": {"tl": "┌", "tr": "┐", "bl": "└", "br": "┘", "hor": "─", "ver": "│"},
|
|
284
|
+
"rounded": {"tl": "╭", "tr": "╮", "bl": "╰", "br": "╯", "hor": "─", "ver": "│"},
|
|
285
|
+
"heavy": {"tl": "┏", "tr": "┓", "bl": "┗", "br": "┛", "hor": "━", "ver": "┃"},
|
|
286
|
+
"double": {"tl": "╔", "tr": "╗", "bl": "╚", "br": "╝", "hor": "═", "ver": "║"},
|
|
287
|
+
}
|
|
288
|
+
c = boxes.get(box_type, boxes["simple"])
|
|
289
|
+
|
|
290
|
+
# Horizontal gradient for top/bottom
|
|
291
|
+
for i in range(w):
|
|
292
|
+
t = i / max(w - 1, 1)
|
|
293
|
+
color = color_start.lerp(color_end, t)
|
|
294
|
+
style = Style(color=color)
|
|
295
|
+
ch = c["hor"] if 0 < i < w - 1 else (c["tl"] if i == 0 else c["tr"])
|
|
296
|
+
self.set(x + i, y, ch, style)
|
|
297
|
+
ch = c["hor"] if 0 < i < w - 1 else (c["bl"] if i == 0 else c["br"])
|
|
298
|
+
self.set(x + i, y + h - 1, ch, style)
|
|
299
|
+
|
|
300
|
+
# Vertical gradient for sides
|
|
301
|
+
for i in range(1, h - 1):
|
|
302
|
+
t = i / max(h - 1, 1)
|
|
303
|
+
color = color_start.lerp(color_end, t)
|
|
304
|
+
style = Style(color=color)
|
|
305
|
+
self.set(x, y + i, c["ver"], style)
|
|
306
|
+
self.set(x + w - 1, y + i, c["ver"], style)
|
|
307
|
+
|
|
308
|
+
return self
|
|
309
|
+
|
|
310
|
+
# ── Circle / ellipse ──
|
|
311
|
+
|
|
312
|
+
def draw_circle(self, cx: int, cy: int, radius: int,
|
|
313
|
+
char: str = "●", style: Optional[Style] = None,
|
|
314
|
+
filled: bool = False) -> Canvas:
|
|
315
|
+
"""Draw a circle (or filled circle)."""
|
|
316
|
+
if filled:
|
|
317
|
+
for y in range(-radius, radius + 1):
|
|
318
|
+
for x in range(-radius, radius + 1):
|
|
319
|
+
if x * x + y * y <= radius * radius:
|
|
320
|
+
self.set(cx + x, cy + y, char, style)
|
|
321
|
+
else:
|
|
322
|
+
x, y = radius, 0
|
|
323
|
+
err = 1 - radius
|
|
324
|
+
while x >= y:
|
|
325
|
+
for dx, dy in [(x, y), (y, x), (-y, x), (-x, y),
|
|
326
|
+
(-x, -y), (-y, -x), (y, -x), (x, -y)]:
|
|
327
|
+
self.set(cx + dx, cy + dy, char, style)
|
|
328
|
+
y += 1
|
|
329
|
+
if err < 0:
|
|
330
|
+
err += 2 * y + 1
|
|
331
|
+
else:
|
|
332
|
+
x -= 1
|
|
333
|
+
err += 2 * (y - x) + 1
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
def draw_line(self, x0: int, y0: int, x1: int, y1: int,
|
|
337
|
+
char: str = "·", style: Optional[Style] = None) -> Canvas:
|
|
338
|
+
"""Draw a line between two points using Bresenham's algorithm."""
|
|
339
|
+
dx = abs(x1 - x0)
|
|
340
|
+
dy = abs(y1 - y0)
|
|
341
|
+
sx = 1 if x0 < x1 else -1
|
|
342
|
+
sy = 1 if y0 < y1 else -1
|
|
343
|
+
err = dx - dy
|
|
344
|
+
|
|
345
|
+
while True:
|
|
346
|
+
self.set(x0, y0, char, style)
|
|
347
|
+
if x0 == x1 and y0 == y1:
|
|
348
|
+
break
|
|
349
|
+
e2 = 2 * err
|
|
350
|
+
if e2 > -dy:
|
|
351
|
+
err -= dy
|
|
352
|
+
x0 += sx
|
|
353
|
+
if e2 < dx:
|
|
354
|
+
err += dx
|
|
355
|
+
y0 += sy
|
|
356
|
+
return self
|
|
357
|
+
|
|
358
|
+
# ── Resize ──
|
|
359
|
+
|
|
360
|
+
def resize(self, width: int, height: int) -> Canvas:
|
|
361
|
+
"""Resize the canvas, preserving existing content where possible."""
|
|
362
|
+
new_cells = [
|
|
363
|
+
[Cell() for _ in range(width)] for _ in range(height)
|
|
364
|
+
]
|
|
365
|
+
for y in range(min(self.height, height)):
|
|
366
|
+
for x in range(min(self.width, width)):
|
|
367
|
+
new_cells[y][x] = self._cells[y][x]
|
|
368
|
+
self.width = width
|
|
369
|
+
self.height = height
|
|
370
|
+
self._cells = new_cells
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
# ── Overlay / compositing ──
|
|
374
|
+
|
|
375
|
+
def overlay(self, other: Canvas, x: int = 0, y: int = 0,
|
|
376
|
+
overwrite: bool = True) -> Canvas:
|
|
377
|
+
"""Overlay another canvas onto this one.
|
|
378
|
+
|
|
379
|
+
overwrite: if False, only paint non-blank cells.
|
|
380
|
+
"""
|
|
381
|
+
for oy in range(other.height):
|
|
382
|
+
for ox in range(other.width):
|
|
383
|
+
tx, ty = x + ox, y + oy
|
|
384
|
+
if self._in_bounds(tx, ty):
|
|
385
|
+
src = other._cells[oy][ox]
|
|
386
|
+
if overwrite or src.char != " ":
|
|
387
|
+
self._cells[ty][tx] = Cell(src.char, src.style)
|
|
388
|
+
return self
|
|
389
|
+
|
|
390
|
+
def copy(self) -> Canvas:
|
|
391
|
+
"""Create a deep copy of this canvas."""
|
|
392
|
+
c = Canvas(self.width, self.height)
|
|
393
|
+
for y in range(self.height):
|
|
394
|
+
for x in range(self.width):
|
|
395
|
+
src = self._cells[y][x]
|
|
396
|
+
c._cells[y][x] = Cell(src.char, src.style, src.z_index)
|
|
397
|
+
return c
|
|
398
|
+
|
|
399
|
+
# ── Inspection ──
|
|
400
|
+
|
|
401
|
+
def get_row(self, y: int) -> List[Cell]:
|
|
402
|
+
"""Get all cells in a row."""
|
|
403
|
+
if 0 <= y < self.height:
|
|
404
|
+
return list(self._cells[y])
|
|
405
|
+
return []
|
|
406
|
+
|
|
407
|
+
def get_col(self, x: int) -> List[Cell]:
|
|
408
|
+
"""Get all cells in a column."""
|
|
409
|
+
if 0 <= x < self.width:
|
|
410
|
+
return [self._cells[y][x] for y in range(self.height)]
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
def get_region(self, x: int, y: int, w: int, h: int) -> List[List[Cell]]:
|
|
414
|
+
"""Get a rectangular region of cells."""
|
|
415
|
+
result = []
|
|
416
|
+
for dy in range(h):
|
|
417
|
+
row = []
|
|
418
|
+
for dx in range(w):
|
|
419
|
+
if self._in_bounds(x + dx, y + dy):
|
|
420
|
+
row.append(self._cells[y + dy][x + dx])
|
|
421
|
+
else:
|
|
422
|
+
row.append(Cell())
|
|
423
|
+
result.append(row)
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
def find(self, char: str) -> List[Tuple[int, int]]:
|
|
427
|
+
"""Find all positions of a character."""
|
|
428
|
+
positions = []
|
|
429
|
+
for y in range(self.height):
|
|
430
|
+
for x in range(self.width):
|
|
431
|
+
if self._cells[y][x].char == char:
|
|
432
|
+
positions.append((x, y))
|
|
433
|
+
return positions
|
|
434
|
+
|
|
435
|
+
def to_text(self) -> str:
|
|
436
|
+
"""Convert canvas to plain text (no ANSI)."""
|
|
437
|
+
lines = []
|
|
438
|
+
for y in range(self.height):
|
|
439
|
+
line = "".join(self._cells[y][x].char for x in range(self.width))
|
|
440
|
+
lines.append(line.rstrip())
|
|
441
|
+
return "\n".join(lines)
|
|
442
|
+
|
|
443
|
+
# ── Render ──
|
|
444
|
+
|
|
445
|
+
def render(self) -> str:
|
|
446
|
+
"""Render canvas to ANSI string for terminal output."""
|
|
447
|
+
lines = []
|
|
448
|
+
for y in range(self.height):
|
|
449
|
+
parts = []
|
|
450
|
+
last_style = None
|
|
451
|
+
for x in range(self.width):
|
|
452
|
+
cell = self._cells[y][x]
|
|
453
|
+
if cell.style != last_style:
|
|
454
|
+
if last_style is not None:
|
|
455
|
+
parts.append(Style.RESET)
|
|
456
|
+
if cell.style is not None:
|
|
457
|
+
parts.append(cell.style.to_ansi())
|
|
458
|
+
last_style = cell.style
|
|
459
|
+
parts.append(cell.char)
|
|
460
|
+
if last_style is not None:
|
|
461
|
+
parts.append(Style.RESET)
|
|
462
|
+
lines.append("".join(parts))
|
|
463
|
+
return "\n".join(lines)
|
|
464
|
+
|
|
465
|
+
def print(self, file=None) -> None:
|
|
466
|
+
"""Print canvas to file (or stdout)."""
|
|
467
|
+
import sys
|
|
468
|
+
f = file or sys.stdout
|
|
469
|
+
f.write("\033[H")
|
|
470
|
+
f.write(self.render())
|
|
471
|
+
f.write("\n")
|
|
472
|
+
f.flush()
|
|
473
|
+
|
|
474
|
+
# ── Context ──
|
|
475
|
+
|
|
476
|
+
def region(self, x: int, y: int, w: int, h: int) -> "CanvasRegion":
|
|
477
|
+
"""Get a sub-region for scoped drawing."""
|
|
478
|
+
return CanvasRegion(self, x, y, w, h)
|
|
479
|
+
|
|
480
|
+
def __getitem__(self, pos: Tuple[int, int]) -> Cell:
|
|
481
|
+
x, y = pos
|
|
482
|
+
return self.get(x, y)
|
|
483
|
+
|
|
484
|
+
def __setitem__(self, pos: Tuple[int, int], value):
|
|
485
|
+
x, y = pos
|
|
486
|
+
if isinstance(value, tuple):
|
|
487
|
+
char, style = value
|
|
488
|
+
self.set(x, y, char, style)
|
|
489
|
+
else:
|
|
490
|
+
self.set_char(x, y, str(value))
|
|
491
|
+
|
|
492
|
+
def __repr__(self):
|
|
493
|
+
return f"Canvas({self.width}x{self.height})"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class CanvasRegion:
|
|
497
|
+
"""A sub-region of a canvas for scoped drawing."""
|
|
498
|
+
|
|
499
|
+
def __init__(self, canvas: Canvas, x: int, y: int, width: int, height: int):
|
|
500
|
+
self._canvas = canvas
|
|
501
|
+
self._ox = x
|
|
502
|
+
self._oy = y
|
|
503
|
+
self.width = width
|
|
504
|
+
self.height = height
|
|
505
|
+
|
|
506
|
+
def set(self, x: int, y: int, char: str, style: Optional[Style] = None) -> CanvasRegion:
|
|
507
|
+
self._canvas.set(self._ox + x, self._oy + y, char, style)
|
|
508
|
+
return self
|
|
509
|
+
|
|
510
|
+
def put(self, x: int, y: int, text: str, style: Optional[Style] = None) -> CanvasRegion:
|
|
511
|
+
self._canvas.put(self._ox + x, self._oy + y, text, style)
|
|
512
|
+
return self
|
|
513
|
+
|
|
514
|
+
def fill(self, char: str = " ", style: Optional[Style] = None) -> CanvasRegion:
|
|
515
|
+
for y in range(self.height):
|
|
516
|
+
for x in range(self.width):
|
|
517
|
+
self._canvas.set(self._ox + x, self._oy + y, char, style)
|
|
518
|
+
return self
|
|
519
|
+
|
|
520
|
+
def clear(self) -> CanvasRegion:
|
|
521
|
+
return self.fill()
|
|
522
|
+
|
|
523
|
+
def draw_box(self, style: Optional[Style] = None,
|
|
524
|
+
box_type: str = "simple") -> CanvasRegion:
|
|
525
|
+
self._canvas.draw_box(self._ox, self._oy, self.width, self.height,
|
|
526
|
+
style=style, box_type=box_type)
|
|
527
|
+
return self
|
|
528
|
+
|
|
529
|
+
def __repr__(self):
|
|
530
|
+
return f"CanvasRegion({self._ox},{self._oy} {self.width}x{self.height})"
|
libtodraw/capture.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Output capture context manager."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from io import StringIO
|
|
5
|
+
from typing import Optional, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .console import Console
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Capture:
|
|
12
|
+
"""Context manager for capturing console output."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, console: "Console"):
|
|
15
|
+
self._console = console
|
|
16
|
+
self._buffer: Optional[StringIO] = None
|
|
17
|
+
self._original_capture: Optional[StringIO] = None
|
|
18
|
+
|
|
19
|
+
def __enter__(self) -> "Capture":
|
|
20
|
+
self._buffer = StringIO()
|
|
21
|
+
self._original_capture = self._console._capture_buffer
|
|
22
|
+
self._console._capture_buffer = self._buffer
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
26
|
+
self._console._capture_buffer = self._original_capture
|
|
27
|
+
|
|
28
|
+
def get(self) -> str:
|
|
29
|
+
"""Get captured output."""
|
|
30
|
+
if self._buffer is not None:
|
|
31
|
+
return self._buffer.getvalue()
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def text(self) -> str:
|
|
36
|
+
"""Get captured text."""
|
|
37
|
+
return self.get()
|