euporie 2.8.4__py3-none-any.whl → 2.8.6__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.
Files changed (131) hide show
  1. euporie/console/_commands.py +143 -0
  2. euporie/console/_settings.py +58 -0
  3. euporie/console/app.py +25 -71
  4. euporie/console/tabs/console.py +58 -62
  5. euporie/core/__init__.py +1 -1
  6. euporie/core/__main__.py +28 -11
  7. euporie/core/_settings.py +109 -0
  8. euporie/core/app/__init__.py +3 -0
  9. euporie/core/app/_commands.py +95 -0
  10. euporie/core/app/_settings.py +457 -0
  11. euporie/core/{app.py → app/app.py} +212 -576
  12. euporie/core/app/base.py +51 -0
  13. euporie/core/{current.py → app/current.py} +13 -4
  14. euporie/core/app/cursor.py +35 -0
  15. euporie/core/app/dummy.py +12 -0
  16. euporie/core/app/launch.py +28 -0
  17. euporie/core/bars/__init__.py +11 -0
  18. euporie/core/bars/command.py +205 -0
  19. euporie/core/bars/menu.py +258 -0
  20. euporie/core/{widgets → bars}/search.py +20 -16
  21. euporie/core/{widgets → bars}/status.py +6 -23
  22. euporie/core/clipboard.py +19 -80
  23. euporie/core/comm/base.py +8 -6
  24. euporie/core/comm/ipywidgets.py +16 -7
  25. euporie/core/comm/registry.py +2 -1
  26. euporie/core/commands.py +10 -20
  27. euporie/core/completion.py +3 -2
  28. euporie/core/config.py +368 -341
  29. euporie/core/convert/__init__.py +0 -30
  30. euporie/core/convert/datum.py +116 -53
  31. euporie/core/convert/formats/__init__.py +31 -0
  32. euporie/core/convert/formats/ansi.py +9 -23
  33. euporie/core/convert/formats/common.py +11 -23
  34. euporie/core/convert/formats/html.py +45 -40
  35. euporie/core/convert/formats/pil.py +1 -1
  36. euporie/core/convert/formats/png.py +3 -5
  37. euporie/core/convert/formats/sixel.py +3 -3
  38. euporie/core/convert/registry.py +4 -6
  39. euporie/core/convert/utils.py +41 -4
  40. euporie/core/diagnostics.py +2 -2
  41. euporie/core/filters.py +98 -40
  42. euporie/core/format.py +2 -3
  43. euporie/core/ft/ansi.py +1 -1
  44. euporie/core/ft/html.py +12 -21
  45. euporie/core/ft/table.py +1 -3
  46. euporie/core/ft/utils.py +4 -1
  47. euporie/core/graphics.py +386 -133
  48. euporie/core/history.py +2 -2
  49. euporie/core/inspection.py +3 -2
  50. euporie/core/io.py +207 -28
  51. euporie/core/kernel/__init__.py +1 -0
  52. euporie/core/{kernel.py → kernel/client.py} +45 -108
  53. euporie/core/kernel/manager.py +114 -0
  54. euporie/core/key_binding/bindings/__init__.py +1 -8
  55. euporie/core/key_binding/bindings/basic.py +47 -7
  56. euporie/core/key_binding/bindings/completion.py +3 -8
  57. euporie/core/key_binding/bindings/micro.py +1 -6
  58. euporie/core/key_binding/bindings/mouse.py +2 -2
  59. euporie/core/key_binding/bindings/terminal.py +193 -0
  60. euporie/core/key_binding/key_processor.py +43 -2
  61. euporie/core/key_binding/registry.py +2 -0
  62. euporie/core/key_binding/utils.py +22 -2
  63. euporie/core/keys.py +7156 -93
  64. euporie/core/layout/cache.py +3 -3
  65. euporie/core/layout/containers.py +48 -4
  66. euporie/core/layout/decor.py +2 -2
  67. euporie/core/layout/mouse.py +1 -1
  68. euporie/core/layout/print.py +2 -1
  69. euporie/core/layout/scroll.py +39 -34
  70. euporie/core/log.py +76 -64
  71. euporie/core/lsp.py +118 -24
  72. euporie/core/margins.py +1 -1
  73. euporie/core/path.py +62 -13
  74. euporie/core/renderer.py +58 -17
  75. euporie/core/style.py +57 -39
  76. euporie/core/suggest.py +103 -85
  77. euporie/core/tabs/__init__.py +32 -0
  78. euporie/core/tabs/_settings.py +113 -0
  79. euporie/core/tabs/base.py +80 -470
  80. euporie/core/tabs/kernel.py +419 -0
  81. euporie/core/tabs/notebook.py +24 -101
  82. euporie/core/utils.py +92 -15
  83. euporie/core/validation.py +1 -1
  84. euporie/core/widgets/_settings.py +188 -0
  85. euporie/core/widgets/cell.py +19 -50
  86. euporie/core/widgets/cell_outputs.py +25 -36
  87. euporie/core/widgets/decor.py +11 -41
  88. euporie/core/widgets/dialog.py +62 -27
  89. euporie/core/widgets/display.py +12 -15
  90. euporie/core/widgets/file_browser.py +2 -23
  91. euporie/core/widgets/forms.py +8 -5
  92. euporie/core/widgets/inputs.py +13 -70
  93. euporie/core/widgets/layout.py +2 -1
  94. euporie/core/widgets/logo.py +49 -0
  95. euporie/core/widgets/menu.py +10 -8
  96. euporie/core/widgets/pager.py +6 -10
  97. euporie/core/widgets/palette.py +6 -6
  98. euporie/hub/app.py +52 -35
  99. euporie/notebook/_commands.py +24 -0
  100. euporie/notebook/_settings.py +107 -0
  101. euporie/notebook/app.py +49 -171
  102. euporie/notebook/filters.py +1 -1
  103. euporie/notebook/tabs/__init__.py +46 -7
  104. euporie/notebook/tabs/_commands.py +714 -0
  105. euporie/notebook/tabs/_settings.py +32 -0
  106. euporie/notebook/tabs/display.py +4 -4
  107. euporie/notebook/tabs/edit.py +11 -44
  108. euporie/notebook/tabs/json.py +5 -5
  109. euporie/notebook/tabs/log.py +1 -18
  110. euporie/notebook/tabs/notebook.py +11 -660
  111. euporie/notebook/widgets/_commands.py +11 -0
  112. euporie/notebook/widgets/_settings.py +19 -0
  113. euporie/notebook/widgets/side_bar.py +14 -34
  114. euporie/preview/_settings.py +104 -0
  115. euporie/preview/app.py +6 -31
  116. euporie/preview/tabs/notebook.py +6 -72
  117. euporie/web/__init__.py +1 -0
  118. euporie/web/tabs/__init__.py +14 -0
  119. euporie/web/tabs/web.py +11 -6
  120. euporie/web/widgets/__init__.py +1 -0
  121. euporie/web/widgets/webview.py +5 -15
  122. {euporie-2.8.4.dist-info → euporie-2.8.6.dist-info}/METADATA +10 -8
  123. euporie-2.8.6.dist-info/RECORD +175 -0
  124. {euporie-2.8.4.dist-info → euporie-2.8.6.dist-info}/WHEEL +1 -1
  125. {euporie-2.8.4.dist-info → euporie-2.8.6.dist-info}/entry_points.txt +2 -2
  126. {euporie-2.8.4.dist-info → euporie-2.8.6.dist-info}/licenses/LICENSE +1 -1
  127. euporie/core/launch.py +0 -64
  128. euporie/core/terminal.py +0 -522
  129. euporie-2.8.4.dist-info/RECORD +0 -147
  130. {euporie-2.8.4.data → euporie-2.8.6.data}/data/share/applications/euporie-console.desktop +0 -0
  131. {euporie-2.8.4.data → euporie-2.8.6.data}/data/share/applications/euporie-notebook.desktop +0 -0
euporie/core/graphics.py CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import logging
6
6
  import weakref
7
7
  from abc import ABCMeta, abstractmethod
8
- from math import ceil
8
+ from math import ceil, floor
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  from prompt_toolkit.cache import FastDictCache, SimpleCache
@@ -20,14 +20,14 @@ from prompt_toolkit.layout.mouse_handlers import MouseHandlers
20
20
  from prompt_toolkit.layout.screen import Char, WritePosition
21
21
  from prompt_toolkit.utils import get_cwidth
22
22
 
23
+ from euporie.core.app.current import get_app
23
24
  from euporie.core.convert.datum import Datum
24
25
  from euporie.core.convert.registry import find_route
25
- from euporie.core.current import get_app
26
26
  from euporie.core.data_structures import DiInt
27
27
  from euporie.core.filters import has_float, in_mplex
28
28
  from euporie.core.ft.utils import _ZERO_WIDTH_FRAGMENTS
29
+ from euporie.core.io import passthrough
29
30
  from euporie.core.layout.scroll import BoundedWritePosition
30
- from euporie.core.terminal import passthrough
31
31
 
32
32
  if TYPE_CHECKING:
33
33
  from typing import Any, Callable, ClassVar
@@ -84,7 +84,7 @@ class GraphicControl(UIControl, metaclass=ABCMeta):
84
84
 
85
85
  @abstractmethod
86
86
  def get_rendered_lines(
87
- self, width: int, height: int, wrap_lines: bool = False
87
+ self, visible_width: int, visible_height: int, wrap_lines: bool = False
88
88
  ) -> list[StyleAndTextTuples]:
89
89
  """Render the output data."""
90
90
  return []
@@ -100,13 +100,9 @@ class GraphicControl(UIControl, metaclass=ABCMeta):
100
100
  `UIContent` for the given output size.
101
101
 
102
102
  """
103
- max_cols, aspect = self.datum.cell_size()
104
- bbox = self.bbox
105
- cols = min(max_cols, width) if max_cols else width
106
- rows = ceil(cols * aspect) - bbox.top - bbox.bottom if aspect else height
107
103
 
108
104
  def get_content() -> dict[str, Any]:
109
- rendered_lines = self.get_rendered_lines(width=cols, height=rows)
105
+ rendered_lines = self.get_rendered_lines(width, height)
110
106
  self.rendered_lines = rendered_lines[:]
111
107
  line_count = len(rendered_lines)
112
108
 
@@ -130,10 +126,10 @@ class GraphicControl(UIControl, metaclass=ABCMeta):
130
126
 
131
127
  # Re-render if the image width changes, or the terminal character size changes
132
128
  key = (
133
- cols,
134
- rows,
129
+ width,
130
+ height,
135
131
  self.app.color_palette,
136
- self.app.term_info.cell_size_px,
132
+ self.app.cell_size_px,
137
133
  self.bbox,
138
134
  )
139
135
  return UIContent(
@@ -155,19 +151,11 @@ class SixelGraphicControl(GraphicControl):
155
151
  def convert_data(self, wp: WritePosition) -> str:
156
152
  """Convert datum to required format."""
157
153
  bbox = wp.bbox if isinstance(wp, BoundedWritePosition) else DiInt(0, 0, 0, 0)
158
- full_width = wp.width + bbox.left + bbox.right
159
- full_height = wp.height + bbox.top + bbox.bottom
160
- cmd = str(
161
- self.datum.convert(
162
- to="sixel",
163
- cols=full_width,
164
- rows=full_height,
165
- )
166
- )
167
- if any(self.bbox):
154
+ cmd = str(self.datum.convert(to="sixel", cols=wp.width, rows=wp.height)).strip()
155
+ if any(bbox):
168
156
  from sixelcrop import sixelcrop
169
157
 
170
- cell_size_x, cell_size_y = self.app.term_info.cell_size_px
158
+ cell_size_x, cell_size_y = self.app.cell_size_px
171
159
 
172
160
  cmd = sixelcrop(
173
161
  data=cmd,
@@ -176,42 +164,80 @@ class SixelGraphicControl(GraphicControl):
176
164
  # Vertical pixel offset of the displayed image region
177
165
  y=bbox.top * cell_size_y,
178
166
  # Pixel width of the displayed image region
179
- w=wp.width * cell_size_x,
167
+ w=(wp.width - bbox.left - bbox.right) * cell_size_x,
180
168
  # Pixel height of the displayed image region
181
- h=wp.height * cell_size_y,
169
+ h=(wp.height - bbox.top - bbox.bottom) * cell_size_y,
182
170
  )
183
171
 
184
172
  return cmd
185
173
 
186
174
  def get_rendered_lines(
187
- self, width: int, height: int, wrap_lines: bool = False
175
+ self, visible_width: int, visible_height: int, wrap_lines: bool = False
188
176
  ) -> list[StyleAndTextTuples]:
189
177
  """Get rendered lines from the cache, or generate them."""
178
+ bbox = self.bbox
179
+
180
+ d_cols, d_aspect = self.datum.cell_size()
181
+ d_rows = d_cols * d_aspect
182
+
183
+ total_available_width = visible_width + bbox.left + bbox.right
184
+ total_available_height = visible_height + bbox.top + bbox.bottom
185
+
186
+ # Scale down the graphic to fit in the available space
187
+ if d_rows > total_available_height or d_cols > total_available_width:
188
+ if d_rows / total_available_height > d_cols / total_available_width:
189
+ ratio = min(1, total_available_height / d_rows)
190
+ else:
191
+ ratio = min(1, total_available_width / d_cols)
192
+ else:
193
+ ratio = 1
194
+
195
+ # Calculate the size and cropping bbox at which we want to display the graphic
196
+ cols = floor(d_cols * ratio)
197
+ rows = ceil(cols * d_aspect)
198
+ d_bbox = DiInt(
199
+ top=self.bbox.top,
200
+ right=max(0, cols - (total_available_width - self.bbox.right)),
201
+ bottom=max(0, rows - (total_available_height - self.bbox.bottom)),
202
+ left=self.bbox.left,
203
+ )
190
204
 
191
205
  def render_lines() -> list[StyleAndTextTuples]:
192
206
  """Render the lines to display in the control."""
193
207
  ft: list[StyleAndTextTuples] = []
194
- if height >= 0:
195
- cmd = self.convert_data(
196
- BoundedWritePosition(0, 0, width, height, self.bbox)
197
- )
208
+ if visible_height >= 0:
209
+ cmd = self.convert_data(BoundedWritePosition(0, 0, cols, rows, d_bbox))
198
210
  ft.extend(
199
211
  split_lines(
200
212
  to_formatted_text(
201
213
  [
202
214
  # Move cursor down and across by image height and width
203
- ("", "\n".join((height) * [" " * (width)])),
215
+ (
216
+ "",
217
+ "\n".join(
218
+ (visible_height) * [" " * (visible_width)]
219
+ ),
220
+ ),
204
221
  # Save position, then move back
205
222
  ("[ZeroWidthEscape]", "\x1b[s"),
206
223
  # Move cursor up if there is more than one line to display
207
224
  *(
208
- [("[ZeroWidthEscape]", f"\x1b[{height-1}A")]
209
- if height > 1
225
+ [
226
+ (
227
+ "[ZeroWidthEscape]",
228
+ f"\x1b[{visible_height - 1}A",
229
+ )
230
+ ]
231
+ if visible_height > 1
210
232
  else []
211
233
  ),
212
- ("[ZeroWidthEscape]", f"\x1b[{width}D"),
234
+ ("[ZeroWidthEscape]", f"\x1b[{visible_width}D"),
213
235
  # Place the image without moving cursor
214
- ("[ZeroWidthEscape]", passthrough(cmd)),
236
+ (
237
+ "[ZeroWidthEscape]",
238
+ passthrough(cmd, self.app.config),
239
+ ),
240
+ # ("[ZeroWidthEscape]", "XXXXX"),
215
241
  # Restore the last known cursor position (at the bottom)
216
242
  ("[ZeroWidthEscape]", "\x1b[u"),
217
243
  ]
@@ -221,9 +247,9 @@ class SixelGraphicControl(GraphicControl):
221
247
  return ft
222
248
 
223
249
  key = (
224
- width,
250
+ visible_width,
225
251
  self.app.color_palette,
226
- self.app.term_info.cell_size_px,
252
+ self.app.cell_size_px,
227
253
  self.bbox,
228
254
  )
229
255
  return self._format_cache.get(key, render_lines)
@@ -235,28 +261,20 @@ class ItermGraphicControl(GraphicControl):
235
261
  def convert_data(self, wp: WritePosition) -> str:
236
262
  """Convert the graphic's data to base64 data."""
237
263
  datum = self.datum
238
-
239
264
  bbox = wp.bbox if isinstance(wp, BoundedWritePosition) else DiInt(0, 0, 0, 0)
240
- full_width = wp.width + bbox.left + bbox.right
241
- full_height = wp.height + bbox.top + bbox.bottom
242
-
243
265
  # Crop image if necessary
244
266
  if any(bbox):
245
267
  import io
246
268
 
247
- image = datum.convert(
248
- to="pil",
249
- cols=full_width,
250
- rows=full_height,
251
- )
269
+ image = datum.convert(to="pil", cols=wp.width, rows=wp.height)
252
270
  if image is not None:
253
- cell_size_x, cell_size_y = self.app.term_info.cell_size_px
271
+ cell_size_x, cell_size_y = self.app.cell_size_px
254
272
  # Downscale image to fit target region for precise cropping
255
- image.thumbnail((full_width * cell_size_x, full_height * cell_size_y))
256
- left = self.bbox.left * cell_size_x
257
- top = self.bbox.top * cell_size_y
258
- right = (self.bbox.left + wp.width) * cell_size_x
259
- bottom = (self.bbox.top + wp.height) * cell_size_y
273
+ image.thumbnail((wp.width * cell_size_x, wp.height * cell_size_y))
274
+ left = bbox.left * cell_size_x
275
+ top = bbox.top * cell_size_y
276
+ right = (wp.width - bbox.right) * cell_size_x
277
+ bottom = (wp.height - bbox.bottom) * cell_size_y
260
278
  upper, lower = sorted((top, bottom))
261
279
  image = image.crop((left, upper, right, lower))
262
280
  with io.BytesIO() as output:
@@ -266,43 +284,81 @@ class ItermGraphicControl(GraphicControl):
266
284
  if datum.format.startswith("base64-"):
267
285
  b64data = datum.data
268
286
  else:
269
- b64data = datum.convert(
270
- to="base64-png",
271
- cols=full_width,
272
- rows=full_height,
273
- )
287
+ b64data = datum.convert(to="base64-png", cols=wp.width, rows=wp.height)
274
288
  return b64data.replace("\n", "").strip()
275
289
 
276
290
  def get_rendered_lines(
277
- self, width: int, height: int, wrap_lines: bool = False
291
+ self, visible_width: int, visible_height: int, wrap_lines: bool = False
278
292
  ) -> list[StyleAndTextTuples]:
279
293
  """Get rendered lines from the cache, or generate them."""
294
+ bbox = self.bbox
295
+
296
+ d_cols, d_aspect = self.datum.cell_size()
297
+ d_rows = d_cols * d_aspect
298
+
299
+ total_available_width = visible_width + bbox.left + bbox.right
300
+ total_available_height = visible_height + bbox.top + bbox.bottom
301
+
302
+ # Scale down the graphic to fit in the available space
303
+ if d_rows > total_available_height or d_cols > total_available_width:
304
+ if d_rows / total_available_height > d_cols / total_available_width:
305
+ ratio = min(1, total_available_height / d_rows)
306
+ else:
307
+ ratio = min(1, total_available_width / d_cols)
308
+ else:
309
+ ratio = 1
310
+
311
+ # Calculate the size and cropping bbox at which we want to display the graphic
312
+ cols = floor(d_cols * ratio)
313
+ rows = ceil(cols * d_aspect)
314
+ d_bbox = DiInt(
315
+ top=self.bbox.top,
316
+ right=max(0, cols - (total_available_width - self.bbox.right)),
317
+ bottom=max(0, rows - (total_available_height - self.bbox.bottom)),
318
+ left=self.bbox.left,
319
+ )
280
320
 
281
321
  def render_lines() -> list[StyleAndTextTuples]:
282
322
  """Render the lines to display in the control."""
283
323
  ft: list[StyleAndTextTuples] = []
284
- if height > 0 and width > 0:
324
+ if (
325
+ rows - d_bbox.top - d_bbox.bottom > 0
326
+ and cols - d_bbox.left - d_bbox.right > 0
327
+ ):
285
328
  b64data = self.convert_data(
286
- BoundedWritePosition(0, 0, width, height, self.bbox)
329
+ BoundedWritePosition(0, 0, cols, rows, d_bbox)
287
330
  )
288
- cmd = f"\x1b]1337;File=inline=1;width={width}:{b64data}\a"
331
+ cmd = f"\x1b]1337;File=inline=1;width={cols}:{b64data}\a"
289
332
  ft.extend(
290
333
  split_lines(
291
334
  to_formatted_text(
292
335
  [
293
336
  # Move cursor down and across by image height and width
294
- ("", "\n".join((height) * [" " * (width)])),
337
+ (
338
+ "",
339
+ "\n".join(
340
+ (visible_height) * [" " * (visible_width)]
341
+ ),
342
+ ),
295
343
  # Save position, then move back
296
344
  ("[ZeroWidthEscape]", "\x1b[s"),
297
345
  # Move cursor up if there is more than one line to display
298
346
  *(
299
- [("[ZeroWidthEscape]", f"\x1b[{height-1}A")]
300
- if height > 1
347
+ [
348
+ (
349
+ "[ZeroWidthEscape]",
350
+ f"\x1b[{visible_height - 1}A",
351
+ )
352
+ ]
353
+ if visible_height > 1
301
354
  else []
302
355
  ),
303
- ("[ZeroWidthEscape]", f"\x1b[{width}D"),
356
+ ("[ZeroWidthEscape]", f"\x1b[{visible_width}D"),
304
357
  # Place the image without moving cursor
305
- ("[ZeroWidthEscape]", passthrough(cmd)),
358
+ (
359
+ "[ZeroWidthEscape]",
360
+ passthrough(cmd, self.app.config),
361
+ ),
306
362
  # Restore the last known cursor position (at the bottom)
307
363
  ("[ZeroWidthEscape]", "\x1b[u"),
308
364
  ]
@@ -312,16 +368,16 @@ class ItermGraphicControl(GraphicControl):
312
368
  return ft
313
369
 
314
370
  key = (
315
- width,
371
+ visible_width,
316
372
  self.app.color_palette,
317
- self.app.term_info.cell_size_px,
373
+ self.app.cell_size_px,
318
374
  self.bbox,
319
375
  )
320
376
  return self._format_cache.get(key, render_lines)
321
377
 
322
378
 
323
- class KittyGraphicControl(GraphicControl):
324
- """A graphic control which displays images using Kitty's graphics protocol."""
379
+ class BaseKittyGraphicControl(GraphicControl):
380
+ """Base graphic control with common methods for both styles of kitty display."""
325
381
 
326
382
  _kitty_image_count: ClassVar[int] = 1
327
383
 
@@ -369,7 +425,7 @@ class KittyGraphicControl(GraphicControl):
369
425
  full_width = wp.width + bbox.left + bbox.right
370
426
  full_height = wp.height + bbox.top + bbox.bottom
371
427
 
372
- datum = self._datum_pad_cache[(self.datum, *self.app.term_info.cell_size_px)]
428
+ datum = self._datum_pad_cache[(self.datum, *self.app.cell_size_px)]
373
429
  return str(
374
430
  datum.convert(
375
431
  to="base64-png",
@@ -389,15 +445,13 @@ class KittyGraphicControl(GraphicControl):
389
445
  cmd += "\x1b\\"
390
446
  return cmd
391
447
 
392
- def load(self, rows: int, cols: int) -> None:
448
+ def load(self, rows: int, cols: int, bbox: DiInt) -> None:
393
449
  """Send the graphic to the terminal without displaying it."""
394
- # global _kitty_image_count
395
-
396
450
  data = self.convert_data(
397
- BoundedWritePosition(0, 0, width=cols, height=rows, bbox=self.bbox)
451
+ BoundedWritePosition(0, 0, width=cols, height=rows, bbox=bbox)
398
452
  )
399
453
  self.kitty_image_id = self._kitty_image_count
400
- KittyGraphicControl._kitty_image_count += 1
454
+ self.__class__._kitty_image_count += 1
401
455
 
402
456
  while data:
403
457
  chunk, data = data[:4096], data[4096:]
@@ -413,25 +467,10 @@ class KittyGraphicControl(GraphicControl):
413
467
  C=1, # Do not move the cursor
414
468
  m=1 if data else 0, # Data will be chunked
415
469
  )
416
- self.app.output.write_raw(passthrough(cmd))
470
+ self.app.output.write_raw(passthrough(cmd, self.app.config))
417
471
  self.app.output.flush()
418
472
  self.loaded = True
419
473
 
420
- def hide(self) -> None:
421
- """Hide the graphic from show without deleting it."""
422
- if self.kitty_image_id > 0:
423
- self.app.output.write_raw(
424
- passthrough(
425
- self._kitty_cmd(
426
- a="d",
427
- d="i",
428
- i=self.kitty_image_id,
429
- q=1,
430
- )
431
- )
432
- )
433
- self.app.output.flush()
434
-
435
474
  def delete(self) -> None:
436
475
  """Delete the graphic from the terminal."""
437
476
  if self.kitty_image_id > 0:
@@ -442,97 +481,306 @@ class KittyGraphicControl(GraphicControl):
442
481
  d="I",
443
482
  i=self.kitty_image_id,
444
483
  q=2,
445
- )
484
+ ),
485
+ self.app.config,
446
486
  )
447
487
  )
448
488
  self.app.output.flush()
449
489
  self.loaded = False
450
490
 
491
+ def reset(self) -> None:
492
+ """Hide and delete the kitty graphic from the terminal."""
493
+ self.hide()
494
+ self.delete()
495
+ super().reset()
496
+
497
+ def close(self) -> None:
498
+ """Remove the displayed object entirely."""
499
+ super().close()
500
+ if not self.app.leave_graphics():
501
+ self.delete()
502
+
503
+
504
+ class KittyGraphicControl(BaseKittyGraphicControl):
505
+ """A graphic control which displays images using Kitty's graphics protocol."""
506
+
451
507
  def get_rendered_lines(
452
- self, width: int, height: int, wrap_lines: bool = False
508
+ self, visible_width: int, visible_height: int, wrap_lines: bool = False
453
509
  ) -> list[StyleAndTextTuples]:
454
510
  """Get rendered lines from the cache, or generate them."""
455
- # TODO - wezterm does not scale kitty graphics, so we might want to resize
456
- # images at this point rather than just loading them once
457
- if not self.loaded:
458
- self.load(cols=width, rows=height)
511
+ bbox = self.bbox
459
512
 
460
- cell_size_px = self.app.term_info.cell_size_px
513
+ cell_size_px = self.app.cell_size_px
461
514
  datum = self._datum_pad_cache[(self.datum, *cell_size_px)]
462
515
  px, py = datum.pixel_size()
463
-
516
+ # Fall back to a default pixel size
464
517
  px = px or 100
465
518
  py = py or 100
466
519
 
520
+ d_cols, d_aspect = datum.cell_size()
521
+ d_rows = d_cols * d_aspect
522
+
523
+ total_available_width = visible_width + bbox.left + bbox.right
524
+ total_available_height = visible_height + bbox.top + bbox.bottom
525
+
526
+ # Scale down the graphic to fit in the available space
527
+ if d_rows > total_available_height or d_cols > total_available_width:
528
+ if d_rows / total_available_height > d_cols / total_available_width:
529
+ ratio = min(1, total_available_height / d_rows)
530
+ else:
531
+ ratio = min(1, total_available_width / d_cols)
532
+ else:
533
+ ratio = 1
534
+
535
+ # Calculate the size and cropping bbox at which we want to display the graphic
536
+ cols = floor(d_cols * ratio)
537
+ rows = ceil(cols * d_aspect)
538
+ d_bbox = DiInt(
539
+ top=self.bbox.top,
540
+ right=max(0, cols - (total_available_width - self.bbox.right)),
541
+ bottom=max(0, rows - (total_available_height - self.bbox.bottom)),
542
+ left=self.bbox.left,
543
+ )
544
+ if not self.loaded:
545
+ self.load(cols=cols, rows=rows, bbox=d_bbox)
546
+
467
547
  def render_lines() -> list[StyleAndTextTuples]:
468
548
  """Render the lines to display in the control."""
469
549
  ft: list[StyleAndTextTuples] = []
470
- if height:
471
- full_width = width + self.bbox.left + self.bbox.right
472
- full_height = height + self.bbox.top + self.bbox.bottom
550
+ if display_rows := rows - d_bbox.top - d_bbox.bottom:
473
551
  cmd = self._kitty_cmd(
474
552
  a="p", # Display a previously transmitted image
475
553
  i=self.kitty_image_id,
476
554
  p=1, # Placement ID
477
555
  m=0, # No batches remaining
478
556
  q=2, # No backchat
479
- c=width,
480
- r=height,
557
+ c=cols - d_bbox.left - d_bbox.right,
558
+ r=display_rows,
481
559
  C=1, # 1 = Do move the cursor
482
560
  # Horizontal pixel offset of the displayed image region
483
- x=int(px * self.bbox.left / full_width),
561
+ x=int(px * d_bbox.left / cols),
484
562
  # Vertical pixel offset of the displayed image region
485
- y=int(py * self.bbox.top / full_height),
563
+ y=int(py * d_bbox.top / rows),
486
564
  # Pixel width of the displayed image region
487
- w=int(px * width / full_width),
565
+ w=int(px * (cols - d_bbox.left - d_bbox.right) / cols),
488
566
  # Pixel height of the displayed image region
489
- h=int(py * height / full_height),
490
- # z=-(2**30) - 1,
567
+ h=int(py * (rows - d_bbox.top - d_bbox.bottom) / rows),
491
568
  )
492
569
  ft.extend(
493
570
  split_lines(
494
571
  to_formatted_text(
495
572
  [
496
573
  # Move cursor down and acoss by image height and width
497
- ("", "\n".join((height) * [" " * (width)])),
574
+ (
575
+ "",
576
+ "\n".join(
577
+ (visible_height) * [" " * (visible_width)]
578
+ ),
579
+ ),
498
580
  # Save position, then move back
499
581
  ("[ZeroWidthEscape]", "\x1b[s"),
500
582
  # Move cursor up if there is more than one line to display
501
583
  *(
502
- [("[ZeroWidthEscape]", f"\x1b[{height-1}A")]
503
- if height > 1
584
+ [
585
+ (
586
+ "[ZeroWidthEscape]",
587
+ f"\x1b[{visible_height - 1}A",
588
+ )
589
+ ]
590
+ if visible_height > 1
504
591
  else []
505
592
  ),
506
- ("[ZeroWidthEscape]", f"\x1b[{width}D"),
593
+ ("[ZeroWidthEscape]", f"\x1b[{visible_width}D"),
507
594
  # Place the image without moving cursor
508
- ("[ZeroWidthEscape]", passthrough(cmd)),
595
+ (
596
+ "[ZeroWidthEscape]",
597
+ passthrough(cmd, self.app.config),
598
+ ),
509
599
  # Restore the last known cursor position (at the bottom)
510
600
  ("[ZeroWidthEscape]", "\x1b[u"),
511
601
  ]
512
602
  )
513
603
  )
514
604
  )
605
+ else:
606
+ ft.append([("[ZeroWidthEscape]", self.hide_cmd())])
515
607
  return ft
516
608
 
517
609
  key = (
518
- width,
610
+ visible_width,
519
611
  self.app.color_palette,
520
- self.app.term_info.cell_size_px,
612
+ self.app.cell_size_px,
521
613
  self.bbox,
522
614
  )
523
615
  return self._format_cache.get(key, render_lines)
524
616
 
525
- def reset(self) -> None:
526
- """Hide and delete the kitty graphic from the terminal."""
527
- self.hide()
528
- self.delete()
529
- super().reset()
617
+ def hide_cmd(self) -> str:
618
+ """Generate a command to hide the graphic."""
619
+ return passthrough(
620
+ self._kitty_cmd(
621
+ a="d",
622
+ d="i",
623
+ i=self.kitty_image_id,
624
+ q=1,
625
+ ),
626
+ self.app.config,
627
+ )
530
628
 
531
- def close(self) -> None:
532
- """Remove the displayed object entirely."""
533
- super().close()
534
- if not self.app.leave_graphics():
535
- self.delete()
629
+ def hide(self) -> None:
630
+ """Hide the graphic from show without deleting it."""
631
+ if self.kitty_image_id > 0:
632
+ self.app.output.write_raw(self.hide_cmd())
633
+ self.app.output.flush()
634
+
635
+
636
+ class KittyUnicodeGraphicControl(BaseKittyGraphicControl):
637
+ """A graphic control which displays images using Kitty's Unicode placeholder mechanism."""
638
+
639
+ PLACEHOLDER = "\U0010eeee" # U+10EEEE placeholder character
640
+ # fmt: off
641
+ DIACRITICS = ( # Diacritics for encoding row/column numbers (0-9)
642
+ "\u0305", "\u030d", "\u030e", "\u0310", "\u0312", "\u033d", "\u033e", "\u033f",
643
+ "\u0346", "\u034a", "\u034b", "\u034c", "\u0350", "\u0351", "\u0352", "\u0357",
644
+ "\u035b", "\u0363", "\u0364", "\u0365", "\u0366", "\u0367", "\u0368", "\u0369",
645
+ "\u036a", "\u036b", "\u036c", "\u036d", "\u036e", "\u036f", "\u0483", "\u0484",
646
+ "\u0485", "\u0486", "\u0487", "\u0592", "\u0593", "\u0594", "\u0595", "\u0597",
647
+ "\u0598", "\u0599", "\u059c", "\u059d", "\u059e", "\u059f", "\u05a0", "\u05a1",
648
+ "\u05a8", "\u05a9", "\u05ab", "\u05ac", "\u05af", "\u05c4", "\u0610", "\u0611",
649
+ "\u0612", "\u0613", "\u0614", "\u0615", "\u0616", "\u0617", "\u0657", "\u0658",
650
+ "\u0659", "\u065a", "\u065b", "\u065d", "\u065e", "\u06d6", "\u06d7", "\u06d8",
651
+ "\u06d9", "\u06da", "\u06db", "\u06dc", "\u06df", "\u06e0", "\u06e1", "\u06e2",
652
+ "\u06e4", "\u06e7", "\u06e8", "\u06eb", "\u06ec", "\u0730", "\u0732", "\u0733",
653
+ "\u0735", "\u0736", "\u073a", "\u073d", "\u073f", "\u0740", "\u0741", "\u0743",
654
+ "\u0745", "\u0747", "\u0749", "\u074a", "\u07eb", "\u07ec", "\u07ed", "\u07ee",
655
+ "\u07ef", "\u07f0", "\u07f1", "\u07f3", "\u0816", "\u0817", "\u0818", "\u0819",
656
+ "\u081b", "\u081c", "\u081d", "\u081e", "\u081f", "\u0820", "\u0821", "\u0822",
657
+ "\u0823", "\u0825", "\u0826", "\u0827", "\u0829", "\u082a", "\u082b", "\u082c",
658
+ "\u082d", "\u0951", "\u0953", "\u0954", "\u0f82", "\u0f83", "\u0f86", "\u0f87",
659
+ "\u135d", "\u135e", "\u135f", "\u17dd", "\u193a", "\u1a17", "\u1a75", "\u1a76",
660
+ "\u1a77", "\u1a78", "\u1a79", "\u1a7a", "\u1a7b", "\u1a7c", "\u1b6b", "\u1b6d",
661
+ "\u1b6e", "\u1b6f", "\u1b70", "\u1b71", "\u1b72", "\u1b73", "\u1cd0", "\u1cd1",
662
+ "\u1cd2", "\u1cda", "\u1cdb", "\u1ce0", "\u1dc0", "\u1dc1", "\u1dc3", "\u1dc4",
663
+ "\u1dc5", "\u1dc6", "\u1dc7", "\u1dc8", "\u1dc9", "\u1dcb", "\u1dcc", "\u1dd1",
664
+ "\u1dd2", "\u1dd3", "\u1dd4", "\u1dd5", "\u1dd6", "\u1dd7", "\u1dd8", "\u1dd9",
665
+ "\u1dda", "\u1ddb", "\u1ddc", "\u1ddd", "\u1dde", "\u1ddf", "\u1de0", "\u1de1",
666
+ "\u1de2", "\u1de3", "\u1de4", "\u1de5", "\u1de6", "\u1dfe", "\u20d0", "\u20d1",
667
+ "\u20d4", "\u20d5", "\u20d6", "\u20d7", "\u20db", "\u20dc", "\u20e1", "\u20e7",
668
+ "\u20e9", "\u20f0", "\u2cef", "\u2cf0", "\u2cf1", "\u2de0", "\u2de1", "\u2de2",
669
+ "\u2de3", "\u2de4", "\u2de5", "\u2de6", "\u2de7", "\u2de8", "\u2de9", "\u2dea",
670
+ "\u2deb", "\u2dec", "\u2ded", "\u2dee", "\u2def", "\u2df0", "\u2df1", "\u2df2",
671
+ "\u2df3", "\u2df4", "\u2df5", "\u2df6", "\u2df7", "\u2df8", "\u2df9", "\u2dfa",
672
+ "\u2dfb", "\u2dfc", "\u2dfd", "\u2dfe", "\u2dff", "\ua66f", "\ua67c", "\ua67d",
673
+ "\ua6f0", "\ua6f1", "\ua8e0", "\ua8e1", "\ua8e2", "\ua8e3", "\ua8e4", "\ua8e5",
674
+ "\ua8e6", "\ua8e7", "\ua8e8", "\ua8e9", "\ua8ea", "\ua8eb", "\ua8ec", "\ua8ed",
675
+ "\ua8ee", "\ua8ef", "\ua8f0", "\ua8f1", "\uaab0", "\uaab2", "\uaab3", "\uaab7",
676
+ "\uaab8", "\uaabe", "\uaabf", "\uaac1", "\ufe20", "\ufe21", "\ufe22", "\ufe23",
677
+ "\ufe24", "\ufe25", "\ufe26",
678
+ "\U00010a0f", "\U00010a38", "\U0001d185", "\U0001d186", "\U0001d187",
679
+ "\U0001d188", "\U0001d189", "\U0001d1aa", "\U0001d1ab", "\U0001d1ac",
680
+ "\U0001d1ad", "\U0001d242", "\U0001d243", "\U0001d244",
681
+ )
682
+ # fmt: on
683
+
684
+ def __init__(
685
+ self,
686
+ datum: Datum,
687
+ scale: float = 0,
688
+ bbox: DiInt | None = None,
689
+ ) -> None:
690
+ """Create a new kitty graphic instance."""
691
+ super().__init__(datum, scale, bbox)
692
+ self.placements: set[tuple[int, int]] = set()
693
+
694
+ def get_rendered_lines(
695
+ self, visible_width: int, visible_height: int, wrap_lines: bool = False
696
+ ) -> list[StyleAndTextTuples]:
697
+ """Get rendered lines from the cache, or generate them."""
698
+ bbox = self.bbox
699
+
700
+ cell_size_px = self.app.cell_size_px
701
+ datum = self._datum_pad_cache[(self.datum, *cell_size_px)]
702
+ px, py = datum.pixel_size()
703
+ # Fall back to a default pixel size
704
+ px = px or 100
705
+ py = py or 100
706
+
707
+ d_cols, d_aspect = datum.cell_size()
708
+ d_rows = d_cols * d_aspect
709
+
710
+ total_available_width = visible_width + bbox.left + bbox.right
711
+ total_available_height = visible_height + bbox.top + bbox.bottom
712
+
713
+ # Scale down the graphic to fit in the available space
714
+ if d_rows > total_available_height or d_cols > total_available_width:
715
+ if d_rows / total_available_height > d_cols / total_available_width:
716
+ ratio = min(1, total_available_height / d_rows)
717
+ else:
718
+ ratio = min(1, total_available_width / d_cols)
719
+ else:
720
+ ratio = 1
721
+
722
+ # Calculate the size and cropping bbox at which we want to display the graphic
723
+ cols = floor(d_cols * ratio)
724
+ rows = ceil(cols * d_aspect)
725
+ if not self.loaded:
726
+ self.load(cols=cols, rows=rows, bbox=DiInt(0, 0, 0, 0))
727
+
728
+ # Add virtual placement for this size if required
729
+ if (cols, rows) not in self.placements:
730
+ cmd = self._kitty_cmd(
731
+ a="p", # Display a previously transmitted image
732
+ i=self.kitty_image_id,
733
+ p=1, # Placement ID
734
+ U=1, # Create a virtual placement
735
+ c=cols,
736
+ r=rows,
737
+ q=2,
738
+ )
739
+ self.app.output.write_raw(passthrough(cmd, self.app.config))
740
+ self.app.output.flush()
741
+ self.placements.add((cols, rows))
742
+
743
+ def render_lines() -> list[StyleAndTextTuples]:
744
+ """Render the lines to display in the control."""
745
+ ft: StyleAndTextTuples = []
746
+
747
+ # Generate placeholder grid
748
+ col_start = bbox.left
749
+ col_stop = cols - bbox.right
750
+ placeholder = self.PLACEHOLDER
751
+ diacritics = self.DIACRITICS
752
+ for row in range(bbox.top, rows - bbox.bottom):
753
+ for col in range(col_start, col_stop):
754
+ ft.extend(
755
+ [
756
+ # We set the ptk-color for the last column so the renderer
757
+ # knows to change the color back after this gets rendered.
758
+ ("fg:#888" if col == col_stop - 1 else "", " "),
759
+ (
760
+ "[ZeroWidthEscape]",
761
+ # We move the cursor back a cell before writing the
762
+ # kitty unicode char using a ZWE
763
+ "\b"
764
+ # Set the kitty graphic and placement we want to render
765
+ # by manually setting an 8-bit foregroun color.
766
+ # The placement ID is set to 1 using underline color.
767
+ f"\x1b[38;5;{self.kitty_image_id}m\x1b[58;1m"
768
+ # Writing the unicode char moves the cursor forward
769
+ # again to where the renderer expects it to be
770
+ f"{placeholder}{diacritics[row]}{diacritics[col]}",
771
+ ),
772
+ ]
773
+ )
774
+ ft.append(("", "\n"))
775
+ return list(split_lines(ft))
776
+
777
+ key = (
778
+ visible_width,
779
+ self.app.color_palette,
780
+ self.app.cell_size_px,
781
+ bbox,
782
+ )
783
+ return self._format_cache.get(key, render_lines)
536
784
 
537
785
 
538
786
  class NotVisible(Exception):
@@ -649,14 +897,13 @@ def select_graphic_control(format_: str) -> type[GraphicControl] | None:
649
897
  """Determine which graphic control to use."""
650
898
  SelectedGraphicControl: type[GraphicControl] | None = None
651
899
  app = get_app()
652
- term_info = app.term_info
653
900
  preferred_graphics_protocol = app.config.graphics
654
901
  useable_graphics_controls: list[type[GraphicControl]] = []
655
902
  _in_mplex = in_mplex()
656
903
  force_graphics = app.config.force_graphics
657
904
 
658
905
  if preferred_graphics_protocol != "none":
659
- if (term_info.iterm_graphics_status.value or force_graphics) and find_route(
906
+ if (app.term_graphics_iterm or force_graphics) and find_route(
660
907
  format_, "base64-png"
661
908
  ):
662
909
  useable_graphics_controls.append(ItermGraphicControl)
@@ -664,23 +911,29 @@ def select_graphic_control(format_: str) -> type[GraphicControl] | None:
664
911
  preferred_graphics_protocol == "iterm"
665
912
  and ItermGraphicControl in useable_graphics_controls
666
913
  # Iterm does not work in mplex without pass-through
667
- and (not _in_mplex or (_in_mplex and app.config.mplex_graphics))
914
+ and (not _in_mplex or (_in_mplex and force_graphics))
668
915
  ):
669
916
  SelectedGraphicControl = ItermGraphicControl
670
917
  elif (
671
- (term_info.kitty_graphics_status.value or force_graphics)
918
+ (app.term_graphics_kitty or force_graphics)
672
919
  and find_route(format_, "base64-png")
673
920
  # Kitty does not work in mplex without pass-through
674
- and (not _in_mplex or (_in_mplex and app.config.mplex_graphics))
921
+ and (not _in_mplex or (_in_mplex and force_graphics))
675
922
  ):
676
923
  useable_graphics_controls.append(KittyGraphicControl)
924
+ useable_graphics_controls.append(KittyUnicodeGraphicControl)
677
925
  if (
678
926
  preferred_graphics_protocol == "kitty"
679
927
  and KittyGraphicControl in useable_graphics_controls
680
928
  ):
681
929
  SelectedGraphicControl = KittyGraphicControl
930
+ elif (
931
+ preferred_graphics_protocol == "kitty-unicode"
932
+ and KittyUnicodeGraphicControl in useable_graphics_controls
933
+ ):
934
+ SelectedGraphicControl = KittyUnicodeGraphicControl
682
935
  # Tmux now supports sixels (>=3.4)
683
- elif (term_info.sixel_graphics_status.value or force_graphics) and find_route(
936
+ elif (app.term_graphics_sixel or force_graphics) and find_route(
684
937
  format_, "sixel"
685
938
  ):
686
939
  useable_graphics_controls.append(SixelGraphicControl)