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/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()
@@ -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()