prezo 0.3.1__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.
- prezo/__init__.py +216 -0
- prezo/app.py +947 -0
- prezo/config.py +247 -0
- prezo/export.py +833 -0
- prezo/images/__init__.py +14 -0
- prezo/images/ascii.py +240 -0
- prezo/images/base.py +111 -0
- prezo/images/chafa.py +137 -0
- prezo/images/iterm.py +126 -0
- prezo/images/kitty.py +360 -0
- prezo/images/overlay.py +291 -0
- prezo/images/processor.py +139 -0
- prezo/images/sixel.py +180 -0
- prezo/parser.py +456 -0
- prezo/screens/__init__.py +21 -0
- prezo/screens/base.py +65 -0
- prezo/screens/blackout.py +60 -0
- prezo/screens/goto.py +99 -0
- prezo/screens/help.py +140 -0
- prezo/screens/overview.py +184 -0
- prezo/screens/search.py +252 -0
- prezo/screens/toc.py +254 -0
- prezo/terminal.py +147 -0
- prezo/themes.py +129 -0
- prezo/widgets/__init__.py +9 -0
- prezo/widgets/image_display.py +117 -0
- prezo/widgets/slide_button.py +72 -0
- prezo/widgets/status_bar.py +240 -0
- prezo-0.3.1.dist-info/METADATA +194 -0
- prezo-0.3.1.dist-info/RECORD +32 -0
- prezo-0.3.1.dist-info/WHEEL +4 -0
- prezo-0.3.1.dist-info/entry_points.txt +3 -0
prezo/images/kitty.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Kitty terminal image renderer using the Kitty graphics protocol.
|
|
2
|
+
|
|
3
|
+
Kitty's protocol differs from iTerm2 in that images persist as overlays
|
|
4
|
+
managed by the terminal itself. They survive application redraws because
|
|
5
|
+
they're not part of the character buffer.
|
|
6
|
+
|
|
7
|
+
Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING, TextIO
|
|
16
|
+
|
|
17
|
+
from typing_extensions import Self
|
|
18
|
+
|
|
19
|
+
from prezo.terminal import ImageCapability, detect_image_capability
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_kitty() -> bool:
|
|
26
|
+
"""Check if running in Kitty terminal."""
|
|
27
|
+
return detect_image_capability() == ImageCapability.KITTY
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class KittyImage:
|
|
32
|
+
"""A Kitty image with persistent ID."""
|
|
33
|
+
|
|
34
|
+
id: int
|
|
35
|
+
path: Path
|
|
36
|
+
width: int
|
|
37
|
+
height: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class KittyImageManager:
|
|
41
|
+
"""Manages Kitty graphics protocol images with persistence.
|
|
42
|
+
|
|
43
|
+
Images are transmitted once and persist in Kitty's memory.
|
|
44
|
+
They can be repositioned or deleted without re-transmission.
|
|
45
|
+
This allows images to coexist with Textual's rendering.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_instance: KittyImageManager | None = None
|
|
49
|
+
_next_id: int = 1
|
|
50
|
+
|
|
51
|
+
def __new__(cls) -> Self:
|
|
52
|
+
"""Create or return singleton instance."""
|
|
53
|
+
if cls._instance is None:
|
|
54
|
+
cls._instance = super().__new__(cls)
|
|
55
|
+
cls._instance._initialized = False
|
|
56
|
+
return cls._instance
|
|
57
|
+
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
"""Initialize the Kitty image manager."""
|
|
60
|
+
if self._initialized:
|
|
61
|
+
return
|
|
62
|
+
self._initialized = True
|
|
63
|
+
self._images: dict[int, KittyImage] = {}
|
|
64
|
+
self._current_image_id: int | None = None
|
|
65
|
+
self._path_to_id: dict[str, int] = {} # Cache path -> id mapping
|
|
66
|
+
|
|
67
|
+
def _get_tty(self) -> TextIO:
|
|
68
|
+
"""Get TTY for direct terminal writes."""
|
|
69
|
+
try:
|
|
70
|
+
return open("/dev/tty", "w")
|
|
71
|
+
except OSError:
|
|
72
|
+
return sys.stdout
|
|
73
|
+
|
|
74
|
+
def _write(self, data: str) -> None:
|
|
75
|
+
"""Write directly to terminal."""
|
|
76
|
+
tty = self._get_tty()
|
|
77
|
+
tty.write(data)
|
|
78
|
+
tty.flush()
|
|
79
|
+
if tty is not sys.stdout:
|
|
80
|
+
tty.close()
|
|
81
|
+
|
|
82
|
+
def display_image(
|
|
83
|
+
self,
|
|
84
|
+
path: Path,
|
|
85
|
+
row: int,
|
|
86
|
+
col: int,
|
|
87
|
+
width: int,
|
|
88
|
+
height: int,
|
|
89
|
+
) -> int:
|
|
90
|
+
"""Display an image at a specific position.
|
|
91
|
+
|
|
92
|
+
If the image was previously transmitted, reuses the cached version.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
path: Path to image file.
|
|
96
|
+
row: Screen row (1-based).
|
|
97
|
+
col: Screen column (1-based).
|
|
98
|
+
width: Display width in cells.
|
|
99
|
+
height: Display height in cells.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Image ID.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
if not path.exists():
|
|
106
|
+
return -1
|
|
107
|
+
|
|
108
|
+
path_key = str(path.absolute())
|
|
109
|
+
|
|
110
|
+
# Check if we already have this image
|
|
111
|
+
if path_key in self._path_to_id:
|
|
112
|
+
image_id = self._path_to_id[path_key]
|
|
113
|
+
# Just reposition it
|
|
114
|
+
self._position_image(image_id, row, col, width, height)
|
|
115
|
+
self._current_image_id = image_id
|
|
116
|
+
return image_id
|
|
117
|
+
|
|
118
|
+
# Need to transmit new image
|
|
119
|
+
image_id = self._transmit_and_display(path, row, col, width, height)
|
|
120
|
+
if image_id > 0:
|
|
121
|
+
self._path_to_id[path_key] = image_id
|
|
122
|
+
self._current_image_id = image_id
|
|
123
|
+
|
|
124
|
+
return image_id
|
|
125
|
+
|
|
126
|
+
def _transmit_and_display(
|
|
127
|
+
self,
|
|
128
|
+
path: Path,
|
|
129
|
+
row: int,
|
|
130
|
+
col: int,
|
|
131
|
+
width: int,
|
|
132
|
+
height: int,
|
|
133
|
+
) -> int:
|
|
134
|
+
"""Transmit and display a new image.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
path: Path to image file.
|
|
138
|
+
row: Screen row (1-based).
|
|
139
|
+
col: Screen column (1-based).
|
|
140
|
+
width: Display width in cells.
|
|
141
|
+
height: Display height in cells.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Image ID for later reference.
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
# Generate unique ID
|
|
148
|
+
image_id = KittyImageManager._next_id
|
|
149
|
+
KittyImageManager._next_id += 1
|
|
150
|
+
|
|
151
|
+
# Read and encode image
|
|
152
|
+
with open(path, "rb") as f:
|
|
153
|
+
data = base64.b64encode(f.read()).decode("ascii")
|
|
154
|
+
|
|
155
|
+
# Move cursor to position first
|
|
156
|
+
self._write(f"\x1b[{row};{col}H")
|
|
157
|
+
|
|
158
|
+
# Transmit and display in chunks
|
|
159
|
+
# a=T: transmit and display
|
|
160
|
+
# i=<id>: image ID for later reference
|
|
161
|
+
# f=100: auto-detect format
|
|
162
|
+
# c=cols, r=rows: size in cells
|
|
163
|
+
# C=1: don't move cursor after display
|
|
164
|
+
chunk_size = 4096
|
|
165
|
+
chunks = [data[i : i + chunk_size] for i in range(0, len(data), chunk_size)]
|
|
166
|
+
|
|
167
|
+
for i, chunk in enumerate(chunks):
|
|
168
|
+
is_last = i == len(chunks) - 1
|
|
169
|
+
m = 0 if is_last else 1
|
|
170
|
+
|
|
171
|
+
if i == 0:
|
|
172
|
+
self._write(
|
|
173
|
+
f"\x1b_Ga=T,i={image_id},f=100,c={width},r={height},C=1,m={m};{chunk}\x1b\\"
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
self._write(f"\x1b_Gm={m};{chunk}\x1b\\")
|
|
177
|
+
|
|
178
|
+
# Store reference
|
|
179
|
+
self._images[image_id] = KittyImage(
|
|
180
|
+
id=image_id, path=path, width=width, height=height
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return image_id
|
|
184
|
+
|
|
185
|
+
def _position_image(
|
|
186
|
+
self,
|
|
187
|
+
image_id: int,
|
|
188
|
+
row: int,
|
|
189
|
+
col: int,
|
|
190
|
+
width: int,
|
|
191
|
+
height: int,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Reposition an already-transmitted image.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
image_id: ID from previous transmission.
|
|
197
|
+
row: Screen row (1-based).
|
|
198
|
+
col: Screen column (1-based).
|
|
199
|
+
width: Display width in cells.
|
|
200
|
+
height: Display height in cells.
|
|
201
|
+
|
|
202
|
+
"""
|
|
203
|
+
if image_id not in self._images:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
# Move cursor and display
|
|
207
|
+
self._write(f"\x1b[{row};{col}H")
|
|
208
|
+
# a=p: put/display previously transmitted image
|
|
209
|
+
self._write(f"\x1b_Ga=p,i={image_id},c={width},r={height},C=1\x1b\\")
|
|
210
|
+
|
|
211
|
+
def delete_image(self, image_id: int) -> None:
|
|
212
|
+
"""Delete a specific image from Kitty's memory."""
|
|
213
|
+
if image_id not in self._images:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
# a=d, d=I: delete by image ID
|
|
217
|
+
self._write(f"\x1b_Ga=d,d=I,i={image_id}\x1b\\")
|
|
218
|
+
|
|
219
|
+
# Clean up references
|
|
220
|
+
img = self._images.pop(image_id)
|
|
221
|
+
path_key = str(img.path.absolute())
|
|
222
|
+
self._path_to_id.pop(path_key, None)
|
|
223
|
+
|
|
224
|
+
if self._current_image_id == image_id:
|
|
225
|
+
self._current_image_id = None
|
|
226
|
+
|
|
227
|
+
def clear_visible(self) -> None:
|
|
228
|
+
"""Clear images from visible screen area (keeps them in memory)."""
|
|
229
|
+
# a=d, d=a: delete all visible placements
|
|
230
|
+
self._write("\x1b_Ga=d,d=a\x1b\\")
|
|
231
|
+
|
|
232
|
+
def delete_all(self) -> None:
|
|
233
|
+
"""Delete all images from Kitty's memory."""
|
|
234
|
+
# a=d, d=A: delete all images including data
|
|
235
|
+
self._write("\x1b_Ga=d,d=A\x1b\\")
|
|
236
|
+
self._images.clear()
|
|
237
|
+
self._path_to_id.clear()
|
|
238
|
+
self._current_image_id = None
|
|
239
|
+
|
|
240
|
+
def hide_current(self) -> None:
|
|
241
|
+
"""Hide the currently displayed image (but keep in memory)."""
|
|
242
|
+
if self._current_image_id is not None:
|
|
243
|
+
# a=d, d=i: delete placement by ID (keeps image data)
|
|
244
|
+
self._write(f"\x1b_Ga=d,d=i,i={self._current_image_id}\x1b\\")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_kitty_manager() -> KittyImageManager:
|
|
248
|
+
"""Get the singleton Kitty image manager."""
|
|
249
|
+
return KittyImageManager()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class KittyRenderer:
|
|
253
|
+
"""Render images using Kitty's graphics protocol."""
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def name(self) -> str:
|
|
257
|
+
"""Get the renderer name."""
|
|
258
|
+
return "kitty"
|
|
259
|
+
|
|
260
|
+
def supports_inline(self) -> bool:
|
|
261
|
+
"""Kitty supports inline images."""
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
def render(self, path: Path, width: int, height: int) -> str:
|
|
265
|
+
"""Render an image using Kitty graphics protocol.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
path: Path to the image file.
|
|
269
|
+
width: Target width in characters (cells).
|
|
270
|
+
height: Target height in lines (cells).
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Escape sequence string to display the image.
|
|
274
|
+
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
return self._render_image(path, width, height)
|
|
278
|
+
except Exception:
|
|
279
|
+
from .ascii import AsciiRenderer
|
|
280
|
+
|
|
281
|
+
return AsciiRenderer()._render_placeholder(path, width, height)
|
|
282
|
+
|
|
283
|
+
def _render_image(self, path: Path, width: int, height: int) -> str:
|
|
284
|
+
"""Render image using Kitty protocol."""
|
|
285
|
+
# Read and encode image
|
|
286
|
+
image_data = path.read_bytes()
|
|
287
|
+
encoded = base64.b64encode(image_data).decode("ascii")
|
|
288
|
+
|
|
289
|
+
# Build Kitty graphics escape sequence
|
|
290
|
+
# Format: \x1b_Ga=T,f=100,s=<w>,v=<h>,c=<cols>,r=<rows>;<base64>\x1b\\
|
|
291
|
+
#
|
|
292
|
+
# a=T: action = transmit and display
|
|
293
|
+
# f=100: format = PNG (auto-detect)
|
|
294
|
+
# c=cols: width in cells
|
|
295
|
+
# r=rows: height in cells
|
|
296
|
+
# m=1: more data follows (for chunked transfer)
|
|
297
|
+
|
|
298
|
+
chunks = []
|
|
299
|
+
chunk_size = 4096
|
|
300
|
+
|
|
301
|
+
for i in range(0, len(encoded), chunk_size):
|
|
302
|
+
chunk = encoded[i : i + chunk_size]
|
|
303
|
+
is_last = i + chunk_size >= len(encoded)
|
|
304
|
+
|
|
305
|
+
if i == 0:
|
|
306
|
+
# First chunk includes all parameters
|
|
307
|
+
params = f"a=T,f=100,c={width},r={height}"
|
|
308
|
+
if not is_last:
|
|
309
|
+
params += ",m=1"
|
|
310
|
+
chunks.append(f"\x1b_G{params};{chunk}\x1b\\")
|
|
311
|
+
else:
|
|
312
|
+
# Subsequent chunks
|
|
313
|
+
m = "0" if is_last else "1"
|
|
314
|
+
chunks.append(f"\x1b_Gm={m};{chunk}\x1b\\")
|
|
315
|
+
|
|
316
|
+
return "".join(chunks)
|
|
317
|
+
|
|
318
|
+
def render_file_direct(self, path: Path, width: int, height: int) -> str:
|
|
319
|
+
"""Render image by sending file path to Kitty.
|
|
320
|
+
|
|
321
|
+
This is more efficient for local files as Kitty reads the file directly.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
path: Path to the image file.
|
|
325
|
+
width: Target width in cells.
|
|
326
|
+
height: Target height in cells.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Escape sequence string.
|
|
330
|
+
|
|
331
|
+
"""
|
|
332
|
+
# Use file path transmission
|
|
333
|
+
# a=T: transmit and display
|
|
334
|
+
# t=f: transmission type = file
|
|
335
|
+
# c, r: cell dimensions
|
|
336
|
+
abs_path = str(path.absolute())
|
|
337
|
+
encoded_path = base64.b64encode(abs_path.encode()).decode("ascii")
|
|
338
|
+
|
|
339
|
+
return f"\x1b_Ga=T,t=f,c={width},r={height};{encoded_path}\x1b\\"
|
|
340
|
+
|
|
341
|
+
def clear(self) -> str:
|
|
342
|
+
"""Return escape sequence to clear all Kitty images."""
|
|
343
|
+
return "\x1b_Ga=d;\x1b\\"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def write_image_to_terminal(path: Path, width: int = 80, height: int = 24) -> None:
|
|
347
|
+
"""Write an image directly to the terminal.
|
|
348
|
+
|
|
349
|
+
Utility function for direct terminal output.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
path: Path to the image file.
|
|
353
|
+
width: Target width in cells.
|
|
354
|
+
height: Target height in cells.
|
|
355
|
+
|
|
356
|
+
"""
|
|
357
|
+
renderer = KittyRenderer()
|
|
358
|
+
output = renderer.render(path, width, height)
|
|
359
|
+
sys.stdout.write(output)
|
|
360
|
+
sys.stdout.flush()
|
prezo/images/overlay.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Image overlay system for native terminal image rendering.
|
|
2
|
+
|
|
3
|
+
This module provides a way to render images directly to the terminal,
|
|
4
|
+
bypassing Textual's virtual buffer. It works by:
|
|
5
|
+
1. Having placeholder widgets reserve space in the TUI
|
|
6
|
+
2. After Textual renders, writing images directly at specific screen coordinates
|
|
7
|
+
3. Using terminal-specific protocols (iTerm2, Kitty, Sixel) for native quality
|
|
8
|
+
|
|
9
|
+
This approach is similar to how ranger and other TUI tools display images.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, TextIO
|
|
18
|
+
|
|
19
|
+
from typing_extensions import Self
|
|
20
|
+
|
|
21
|
+
from prezo.terminal import ImageCapability, detect_image_capability
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ImageRequest:
|
|
30
|
+
"""Request to render an image at a specific screen position."""
|
|
31
|
+
|
|
32
|
+
path: Path
|
|
33
|
+
row: int # 1-based screen row
|
|
34
|
+
col: int # 1-based screen column
|
|
35
|
+
width: int # Width in characters
|
|
36
|
+
height: int # Height in lines
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ImageOverlayRenderer:
|
|
40
|
+
"""Renders images directly to the terminal, bypassing Textual's buffer.
|
|
41
|
+
|
|
42
|
+
This is a singleton that manages all image rendering for the application.
|
|
43
|
+
It hooks into Textual's render cycle and draws images after Textual
|
|
44
|
+
has finished its own rendering.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_instance: ImageOverlayRenderer | None = None
|
|
48
|
+
|
|
49
|
+
def __new__(cls) -> Self:
|
|
50
|
+
"""Create or return singleton instance."""
|
|
51
|
+
if cls._instance is None:
|
|
52
|
+
cls._instance = super().__new__(cls)
|
|
53
|
+
cls._instance._initialized = False
|
|
54
|
+
return cls._instance
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
"""Initialize the image overlay renderer."""
|
|
58
|
+
if self._initialized:
|
|
59
|
+
return
|
|
60
|
+
self._initialized = True
|
|
61
|
+
self._pending_images: list[ImageRequest] = []
|
|
62
|
+
self._capability = detect_image_capability()
|
|
63
|
+
self._last_rendered: list[ImageRequest] = []
|
|
64
|
+
|
|
65
|
+
@contextlib.contextmanager
|
|
66
|
+
def _get_tty(self) -> Iterator[TextIO]:
|
|
67
|
+
"""Get TTY for direct terminal writes as a context manager."""
|
|
68
|
+
tty: TextIO | None = None
|
|
69
|
+
try:
|
|
70
|
+
tty = open("/dev/tty", "w") # noqa: SIM115
|
|
71
|
+
yield tty
|
|
72
|
+
except OSError:
|
|
73
|
+
yield sys.stdout
|
|
74
|
+
finally:
|
|
75
|
+
if tty is not None and tty is not sys.stdout:
|
|
76
|
+
tty.close()
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def supports_native_images(self) -> bool:
|
|
80
|
+
"""Check if terminal supports native image protocols."""
|
|
81
|
+
return self._capability in (
|
|
82
|
+
ImageCapability.KITTY,
|
|
83
|
+
ImageCapability.ITERM,
|
|
84
|
+
ImageCapability.SIXEL,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def queue_image(
|
|
88
|
+
self,
|
|
89
|
+
path: Path,
|
|
90
|
+
row: int,
|
|
91
|
+
col: int,
|
|
92
|
+
width: int,
|
|
93
|
+
height: int,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Queue an image to be rendered at the specified position.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
path: Path to the image file.
|
|
99
|
+
row: Screen row (1-based).
|
|
100
|
+
col: Screen column (1-based).
|
|
101
|
+
width: Width in characters.
|
|
102
|
+
height: Height in lines.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
self._pending_images.append(
|
|
106
|
+
ImageRequest(path=path, row=row, col=col, width=width, height=height)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def clear_queue(self) -> None:
|
|
110
|
+
"""Clear all pending image requests."""
|
|
111
|
+
self._pending_images.clear()
|
|
112
|
+
|
|
113
|
+
def render_pending(self) -> None:
|
|
114
|
+
"""Render all pending images to the terminal.
|
|
115
|
+
|
|
116
|
+
This should be called after Textual has finished rendering.
|
|
117
|
+
"""
|
|
118
|
+
if not self._pending_images:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
if not self.supports_native_images:
|
|
122
|
+
self._pending_images.clear()
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Write directly to TTY to bypass Textual's output handling
|
|
126
|
+
with self._get_tty() as tty:
|
|
127
|
+
# Save cursor position
|
|
128
|
+
tty.write("\x1b[s")
|
|
129
|
+
|
|
130
|
+
for request in self._pending_images:
|
|
131
|
+
self._render_image(request, tty)
|
|
132
|
+
|
|
133
|
+
# Restore cursor position
|
|
134
|
+
tty.write("\x1b[u")
|
|
135
|
+
tty.flush()
|
|
136
|
+
|
|
137
|
+
self._last_rendered = self._pending_images.copy()
|
|
138
|
+
self._pending_images.clear()
|
|
139
|
+
|
|
140
|
+
def clear_images(self) -> None:
|
|
141
|
+
"""Clear previously rendered images from the screen.
|
|
142
|
+
|
|
143
|
+
This should be called before Textual re-renders to avoid artifacts.
|
|
144
|
+
"""
|
|
145
|
+
if not self._last_rendered:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# For Kitty, we can delete images by ID
|
|
149
|
+
# For iTerm2 and Sixel, we just let Textual overwrite them
|
|
150
|
+
if self._capability == ImageCapability.KITTY:
|
|
151
|
+
# Delete all images
|
|
152
|
+
with self._get_tty() as tty:
|
|
153
|
+
tty.write("\x1b_Ga=d\x1b\\")
|
|
154
|
+
tty.flush()
|
|
155
|
+
|
|
156
|
+
self._last_rendered.clear()
|
|
157
|
+
|
|
158
|
+
def rerender_last(self) -> None:
|
|
159
|
+
"""Re-render the last rendered images.
|
|
160
|
+
|
|
161
|
+
Call this periodically to keep images visible after Textual redraws.
|
|
162
|
+
"""
|
|
163
|
+
if not self._last_rendered or not self.supports_native_images:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
with self._get_tty() as tty:
|
|
167
|
+
# Save cursor position
|
|
168
|
+
tty.write("\x1b[s")
|
|
169
|
+
|
|
170
|
+
for request in self._last_rendered:
|
|
171
|
+
self._render_image_quiet(request, tty)
|
|
172
|
+
|
|
173
|
+
# Restore cursor position
|
|
174
|
+
tty.write("\x1b[u")
|
|
175
|
+
tty.flush()
|
|
176
|
+
|
|
177
|
+
def _render_image_quiet(self, request: ImageRequest, tty) -> None:
|
|
178
|
+
"""Render a single image without debug logging."""
|
|
179
|
+
if not request.path.exists():
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Move cursor to position
|
|
183
|
+
tty.write(f"\x1b[{request.row};{request.col}H")
|
|
184
|
+
|
|
185
|
+
# Render using appropriate protocol
|
|
186
|
+
if self._capability == ImageCapability.ITERM:
|
|
187
|
+
self._render_iterm_quiet(request, tty)
|
|
188
|
+
elif self._capability == ImageCapability.KITTY:
|
|
189
|
+
self._render_kitty(request, tty)
|
|
190
|
+
elif self._capability == ImageCapability.SIXEL:
|
|
191
|
+
self._render_sixel(request, tty)
|
|
192
|
+
|
|
193
|
+
def _render_iterm_quiet(self, request: ImageRequest, tty) -> None:
|
|
194
|
+
"""Render iTerm2 image without debug logging."""
|
|
195
|
+
import base64
|
|
196
|
+
|
|
197
|
+
with open(request.path, "rb") as f:
|
|
198
|
+
raw_data = f.read()
|
|
199
|
+
image_data = base64.b64encode(raw_data).decode("ascii")
|
|
200
|
+
|
|
201
|
+
params = (
|
|
202
|
+
f"name={base64.b64encode(request.path.name.encode()).decode('ascii')};"
|
|
203
|
+
f"size={len(raw_data)};"
|
|
204
|
+
f"width={request.width};"
|
|
205
|
+
f"height={request.height};"
|
|
206
|
+
f"inline=1;"
|
|
207
|
+
f"preserveAspectRatio=1"
|
|
208
|
+
)
|
|
209
|
+
tty.write(f"\x1b]1337;File={params}:{image_data}\x07")
|
|
210
|
+
tty.flush()
|
|
211
|
+
|
|
212
|
+
def _render_image(self, request: ImageRequest, tty) -> None:
|
|
213
|
+
"""Render a single image at its specified position."""
|
|
214
|
+
if not request.path.exists():
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
# Move cursor to position
|
|
218
|
+
tty.write(f"\x1b[{request.row};{request.col}H")
|
|
219
|
+
|
|
220
|
+
# Render using appropriate protocol
|
|
221
|
+
if self._capability == ImageCapability.ITERM:
|
|
222
|
+
self._render_iterm(request, tty)
|
|
223
|
+
elif self._capability == ImageCapability.KITTY:
|
|
224
|
+
self._render_kitty(request, tty)
|
|
225
|
+
elif self._capability == ImageCapability.SIXEL:
|
|
226
|
+
self._render_sixel(request, tty)
|
|
227
|
+
|
|
228
|
+
def _render_iterm(self, request: ImageRequest, tty) -> None:
|
|
229
|
+
"""Render image using iTerm2 inline image protocol."""
|
|
230
|
+
import base64
|
|
231
|
+
|
|
232
|
+
with open(request.path, "rb") as f:
|
|
233
|
+
raw_data = f.read()
|
|
234
|
+
image_data = base64.b64encode(raw_data).decode("ascii")
|
|
235
|
+
|
|
236
|
+
# iTerm2 inline image protocol
|
|
237
|
+
# Width and height in cells
|
|
238
|
+
params = (
|
|
239
|
+
f"name={base64.b64encode(request.path.name.encode()).decode('ascii')};"
|
|
240
|
+
f"size={len(raw_data)};"
|
|
241
|
+
f"width={request.width};"
|
|
242
|
+
f"height={request.height};"
|
|
243
|
+
f"inline=1;"
|
|
244
|
+
f"preserveAspectRatio=1"
|
|
245
|
+
)
|
|
246
|
+
escape_seq = f"\x1b]1337;File={params}:{image_data}\x07"
|
|
247
|
+
tty.write(escape_seq)
|
|
248
|
+
tty.flush()
|
|
249
|
+
|
|
250
|
+
def _render_kitty(self, request: ImageRequest, tty) -> None:
|
|
251
|
+
"""Render image using Kitty graphics protocol."""
|
|
252
|
+
import base64
|
|
253
|
+
|
|
254
|
+
with open(request.path, "rb") as f:
|
|
255
|
+
image_data = base64.b64encode(f.read()).decode("ascii")
|
|
256
|
+
|
|
257
|
+
# Kitty graphics protocol
|
|
258
|
+
# a=T: transmit and display
|
|
259
|
+
# f=100: PNG format (auto-detect)
|
|
260
|
+
# c=width, r=height in cells
|
|
261
|
+
chunk_size = 4096
|
|
262
|
+
chunks = [
|
|
263
|
+
image_data[i : i + chunk_size]
|
|
264
|
+
for i in range(0, len(image_data), chunk_size)
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
for i, chunk in enumerate(chunks):
|
|
268
|
+
is_last = i == len(chunks) - 1
|
|
269
|
+
m = 0 if is_last else 1 # m=1 means more chunks coming
|
|
270
|
+
|
|
271
|
+
if i == 0:
|
|
272
|
+
# First chunk includes parameters
|
|
273
|
+
tty.write(
|
|
274
|
+
f"\x1b_Ga=T,f=100,c={request.width},r={request.height},m={m};"
|
|
275
|
+
f"{chunk}\x1b\\"
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
tty.write(f"\x1b_Gm={m};{chunk}\x1b\\")
|
|
279
|
+
|
|
280
|
+
def _render_sixel(self, request: ImageRequest, tty) -> None:
|
|
281
|
+
"""Render image using Sixel graphics."""
|
|
282
|
+
from .sixel import SixelRenderer
|
|
283
|
+
|
|
284
|
+
renderer = SixelRenderer()
|
|
285
|
+
sixel_data = renderer.render(request.path, request.width, request.height)
|
|
286
|
+
tty.write(sixel_data)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_overlay_renderer() -> ImageOverlayRenderer:
|
|
290
|
+
"""Get the singleton overlay renderer instance."""
|
|
291
|
+
return ImageOverlayRenderer()
|