euporie 2.6.1__py3-none-any.whl → 2.7.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.
Files changed (67) hide show
  1. euporie/console/tabs/console.py +51 -43
  2. euporie/core/__init__.py +5 -2
  3. euporie/core/app.py +74 -57
  4. euporie/core/comm/ipywidgets.py +7 -3
  5. euporie/core/config.py +51 -27
  6. euporie/core/convert/__init__.py +2 -0
  7. euporie/core/convert/datum.py +82 -45
  8. euporie/core/convert/formats/ansi.py +1 -2
  9. euporie/core/convert/formats/common.py +7 -11
  10. euporie/core/convert/formats/ft.py +10 -7
  11. euporie/core/convert/formats/png.py +7 -6
  12. euporie/core/convert/formats/sixel.py +1 -1
  13. euporie/core/convert/formats/svg.py +28 -0
  14. euporie/core/convert/mime.py +4 -7
  15. euporie/core/data_structures.py +24 -22
  16. euporie/core/filters.py +16 -2
  17. euporie/core/format.py +30 -4
  18. euporie/core/ft/ansi.py +2 -1
  19. euporie/core/ft/html.py +155 -42
  20. euporie/core/{widgets/graphics.py → graphics.py} +225 -227
  21. euporie/core/io.py +8 -0
  22. euporie/core/key_binding/bindings/__init__.py +8 -2
  23. euporie/core/key_binding/bindings/basic.py +9 -14
  24. euporie/core/key_binding/bindings/micro.py +0 -12
  25. euporie/core/key_binding/bindings/mouse.py +107 -80
  26. euporie/core/key_binding/bindings/page_navigation.py +129 -0
  27. euporie/core/key_binding/key_processor.py +9 -1
  28. euporie/core/layout/__init__.py +1 -0
  29. euporie/core/layout/containers.py +1011 -0
  30. euporie/core/layout/decor.py +381 -0
  31. euporie/core/layout/print.py +130 -0
  32. euporie/core/layout/screen.py +75 -0
  33. euporie/core/{widgets/page.py → layout/scroll.py} +166 -111
  34. euporie/core/log.py +1 -1
  35. euporie/core/margins.py +11 -5
  36. euporie/core/path.py +43 -176
  37. euporie/core/renderer.py +31 -8
  38. euporie/core/style.py +2 -0
  39. euporie/core/tabs/base.py +2 -1
  40. euporie/core/terminal.py +19 -21
  41. euporie/core/widgets/cell.py +2 -4
  42. euporie/core/widgets/cell_outputs.py +2 -2
  43. euporie/core/widgets/decor.py +3 -359
  44. euporie/core/widgets/dialog.py +5 -5
  45. euporie/core/widgets/display.py +32 -12
  46. euporie/core/widgets/file_browser.py +3 -4
  47. euporie/core/widgets/forms.py +36 -14
  48. euporie/core/widgets/inputs.py +171 -99
  49. euporie/core/widgets/layout.py +80 -5
  50. euporie/core/widgets/menu.py +1 -3
  51. euporie/core/widgets/pager.py +3 -3
  52. euporie/core/widgets/palette.py +3 -2
  53. euporie/core/widgets/status_bar.py +2 -6
  54. euporie/core/widgets/tree.py +3 -6
  55. euporie/notebook/app.py +8 -8
  56. euporie/notebook/tabs/notebook.py +2 -2
  57. euporie/notebook/widgets/side_bar.py +1 -1
  58. euporie/preview/tabs/notebook.py +2 -2
  59. euporie/web/tabs/web.py +6 -1
  60. euporie/web/widgets/webview.py +52 -32
  61. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/METADATA +9 -11
  62. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/RECORD +67 -60
  63. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/WHEEL +1 -1
  64. {euporie-2.6.1.data → euporie-2.7.0.data}/data/share/applications/euporie-console.desktop +0 -0
  65. {euporie-2.6.1.data → euporie-2.7.0.data}/data/share/applications/euporie-notebook.desktop +0 -0
  66. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/entry_points.txt +0 -0
  67. {euporie-2.6.1.dist-info → euporie-2.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import asyncio
5
5
  import hashlib
6
+ import inspect
6
7
  import io
7
8
  import logging
8
9
  import threading
@@ -13,6 +14,7 @@ import imagesize
13
14
  from PIL.Image import Image as PilImage
14
15
  from prompt_toolkit.cache import SimpleCache
15
16
  from prompt_toolkit.data_structures import Size
17
+ from prompt_toolkit.layout.containers import WindowAlign
16
18
 
17
19
  from euporie.core.convert.registry import (
18
20
  _CONVERTOR_ROUTE_CACHE,
@@ -26,18 +28,20 @@ if TYPE_CHECKING:
26
28
  from pathlib import Path
27
29
  from typing import Any, ClassVar, Coroutine
28
30
 
31
+ from prompt_toolkit.formatted_text.base import StyleAndTextTuples
29
32
  from rich.console import ConsoleRenderable
30
33
 
34
+ from euporie.core.data_structures import DiInt
31
35
  from euporie.core.style import ColorPaletteColor
32
36
 
33
37
 
34
- T = TypeVar("T", bytes, str, PilImage, "ConsoleRenderable")
38
+ T = TypeVar("T", bytes, str, "StyleAndTextTuples", PilImage, "ConsoleRenderable")
35
39
 
36
40
 
37
41
  log = logging.getLogger(__name__)
38
42
 
39
43
 
40
- ERROR_OUTPUTS = {
44
+ ERROR_OUTPUTS: dict[str, Any] = {
41
45
  "ansi": "(Format Conversion Error)",
42
46
  "ft": [("fg:white bg:darkred", "(Format Conversion Error)")],
43
47
  }
@@ -77,27 +81,42 @@ def get_loop() -> asyncio.AbstractEventLoop:
77
81
  return _LOOP[0]
78
82
 
79
83
 
80
- class Datum(Generic[T]):
84
+ class _MetaDatum(type):
85
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
86
+ super().__init__(*args, **kwargs)
87
+ self._instances: WeakValueDictionary[
88
+ tuple[Any, ...], Datum
89
+ ] = WeakValueDictionary()
90
+
91
+ def __call__(self, data: T, *args: Any, **kwargs: Any) -> Datum[T]:
92
+ data_hash = Datum.get_hash(data)
93
+ key: tuple[Any, ...] = (
94
+ data_hash,
95
+ *args,
96
+ # Get defaults for non-passed kwargs
97
+ *(
98
+ kwargs.get(param.name, param.default)
99
+ for param in inspect.signature(Datum.__init__).parameters.values()
100
+ if param.default is not inspect._empty
101
+ and param.name not in {"path", "source"}
102
+ ),
103
+ )
104
+ if key in self._instances:
105
+ return self._instances[key]
106
+ instance = super().__call__(data, *args, **kwargs)
107
+ self._instances[key] = instance
108
+ return instance
109
+
110
+
111
+ class Datum(Generic[T], metaclass=_MetaDatum):
81
112
  """Class for storing and converting display data."""
82
113
 
83
114
  _pixel_size: tuple[int | None, int | None]
84
115
  _hash: str
85
116
  _root: ReferenceType[Datum]
86
117
 
87
- _instances: WeakValueDictionary[str, Datum] = WeakValueDictionary()
88
118
  _sizes: ClassVar[dict[str, tuple[ReferenceType[Datum], Size]]] = {}
89
119
 
90
- def __new__(cls, data: T, *args: Any, **kwargs: Any) -> Datum:
91
- """Create a single instance based on unique data."""
92
- data_hash = cls.get_hash(data)
93
-
94
- if instance := cls._instances.get(data_hash):
95
- return instance
96
-
97
- instance = super().__new__(cls)
98
- cls._instances[data_hash] = instance
99
- return instance
100
-
101
120
  def __init__(
102
121
  self,
103
122
  data: T,
@@ -108,24 +127,37 @@ class Datum(Generic[T]):
108
127
  bg: ColorPaletteColor | str | None = None,
109
128
  path: Path | None = None,
110
129
  source: Datum | None = None,
130
+ align: WindowAlign = WindowAlign.LEFT,
111
131
  ) -> None:
112
132
  """Create a new instance of display data."""
113
133
  # self.self = self
114
134
  self.data: T = data
115
135
  self.format = format
116
136
  self.px, self.py = px, py
117
- self.fg = str(fg) if fg else None
118
- self.bg = str(bg) if bg else None
137
+ self._fg = str(fg) if fg else None
138
+ self._bg = str(bg) if bg else None
119
139
  self.path = path
120
140
  self.source: ReferenceType[Datum] = ref(source) if source else ref(self)
121
-
141
+ self.align = align
122
142
  self._cell_size: tuple[int, float] | None = None
123
-
124
- self._conversions: dict[tuple[str, int | None, int | None, bool], T] = {}
125
-
143
+ self._conversions: dict[tuple[str, int | None, int | None, bool], T | None] = {}
126
144
  self._finalizer = finalize(self, self._cleanup_datum_sizes, self.hash)
127
145
  self._finalizer.atexit = False
128
146
 
147
+ @property
148
+ def fg(self) -> str:
149
+ """The foreground color."""
150
+ if not (fg := self._fg) and hasattr(app := get_app(), "color_palette"):
151
+ return app.color_palette.fg.base_hex
152
+ return fg or "#ffffff"
153
+
154
+ @property
155
+ def bg(self) -> str:
156
+ """The background color."""
157
+ if not (bg := self._bg) and hasattr(app := get_app(), "color_palette"):
158
+ return app.color_palette.bg.base_hex
159
+ return bg or "#000000"
160
+
129
161
  def __repr__(self) -> str:
130
162
  """Return a string representation of object."""
131
163
  return f"{self.__class__.__name__}(format={self.format!r})"
@@ -196,45 +228,44 @@ class Datum(Generic[T]):
196
228
  cols: int | None = None,
197
229
  rows: int | None = None,
198
230
  extend: bool = True,
231
+ bbox: DiInt | None = None,
199
232
  ) -> Any:
200
233
  """Perform conversion asynchronously, caching the result."""
201
234
  if to == self.format:
202
235
  # TODO - crop
203
236
  return self.data
204
237
 
205
- if to in self._conversions:
238
+ if (to, cols, rows, extend) in self._conversions:
206
239
  return self._conversions[to, cols, rows, extend]
207
240
 
208
241
  routes = _CONVERTOR_ROUTE_CACHE[(self.format, to)]
209
242
  # log.debug(
210
243
  # "Converting from '%s' to '%s' using route: %s", self, to, routes
211
244
  # )
212
- if not routes:
213
- # raise NotImplementedError(
214
- # f"Cannot convert from `self.format` to `to`"
215
- # )
216
- log.warning("Cannot convert from `%s` to `%s`", self.format, to)
217
- output = None
218
- else:
245
+ output: T | None = None
246
+ if routes:
219
247
  datum = self
220
248
  output = None
221
249
  for route in routes:
222
250
  for stage_a, stage_b in zip(route, route[1:]):
223
- # Find converter with lowest weight
224
- func = sorted(
225
- [
226
- conv
227
- for conv in converters[stage_b][stage_a]
228
- if _FILTER_CACHE.get((conv,), conv.filter_)
229
- ],
230
- key=lambda x: x.weight,
231
- )[0].func
232
- try:
233
- output = await func(datum, cols, rows, extend)
234
- self._conversions[stage_b, cols, rows, extend] = output
235
- except Exception:
236
- log.exception("An error occurred during format conversion")
237
- output = None
251
+ if (stage_b, cols, rows, extend) in self._conversions:
252
+ output = self._conversions[stage_b, cols, rows, extend]
253
+ else:
254
+ # Find converter with lowest weight
255
+ func = sorted(
256
+ [
257
+ conv
258
+ for conv in converters[stage_b][stage_a]
259
+ if _FILTER_CACHE.get((conv,), conv.filter_)
260
+ ],
261
+ key=lambda x: x.weight,
262
+ )[0].func
263
+ try:
264
+ output = await func(datum, cols, rows, extend)
265
+ self._conversions[stage_b, cols, rows, extend] = output
266
+ except Exception:
267
+ log.exception("An error occurred during format conversion")
268
+ output = None
238
269
  if output is None:
239
270
  log.error(
240
271
  "Failed to convert `%s`"
@@ -261,6 +292,9 @@ class Datum(Generic[T]):
261
292
  # If this route succeeded, stop trying routes
262
293
  break
263
294
 
295
+ # Crop or pad output
296
+ # if bbox and any(bbox):
297
+
264
298
  if output is None:
265
299
  output = ERROR_OUTPUTS.get(to, "(Conversion Error)")
266
300
 
@@ -299,7 +333,10 @@ class Datum(Generic[T]):
299
333
  # Do not bother trying if the format is ANSI
300
334
  if self.format != "ansi" and (px is None or py is None):
301
335
  # Try using imagesize to get the size of the output
302
- if self.format not in {"png", "svg", "jpeg", "gif", "tiff"}:
336
+ if (
337
+ self.format not in {"png", "svg", "jpeg", "gif", "tiff"}
338
+ and _CONVERTOR_ROUTE_CACHE[(self.format, "png")]
339
+ ):
303
340
  data = await self.convert_async(to="png")
304
341
  else:
305
342
  data = self.data
@@ -246,10 +246,9 @@ async def pil_to_ansi_py_timg(
246
246
  # resizing the image
247
247
  data = data.resize((cols, ceil(rows * 2 * (px / py) / 0.5)))
248
248
 
249
- bg = str(datum.bg) or get_app().color_palette.bg.base_hex
249
+ bg = str(datum.bg or get_app().color_palette.bg.base_hex)
250
250
  if bg:
251
251
  data = set_background(data, bg)
252
- data = set_background(data, bg)
253
252
  return timg.Ansi24HblockMethod(data).to_string()
254
253
 
255
254
 
@@ -46,7 +46,7 @@ async def imagemagick_convert(
46
46
  if not bg and hasattr(app, "color_palette"):
47
47
  bg = app.color_palette.bg.base_hex
48
48
  if bg:
49
- cmd += ["-background", str(bg)]
49
+ cmd += ["-background", str(bg), "-flatten"]
50
50
  cmd += ["-[0]", f"{output_format}:-"]
51
51
  result: bytes | str = await call_subproc(datum.data, cmd)
52
52
 
@@ -86,6 +86,7 @@ async def chafa_convert_py(
86
86
  ) -> str | bytes:
87
87
  """Convert image data to ANSI text using ::`chafa.py`."""
88
88
  from chafa.chafa import Canvas, CanvasConfig, PixelMode, PixelType
89
+ from PIL import Image
89
90
 
90
91
  pil_mode_to_pixel_type = {
91
92
  "RGBa": PixelType.CHAFA_PIXEL_RGBA8_PREMULTIPLIED,
@@ -102,10 +103,9 @@ async def chafa_convert_py(
102
103
 
103
104
  # Convert PIL image to format that chafa can use
104
105
  data = datum.data
105
- if data.mode not in pil_mode_to_pixel_type:
106
- from PIL import Image
107
-
108
- data = data.convert("RGBA", palette=Image.Palette.ADAPTIVE, colors=16)
106
+ # Always convert the image, as unconverted images sometime result in an off-by-one
107
+ # line width errors resulting in diagonal image striping for some reason
108
+ data = data.convert("RGBA", palette=Image.Palette.ADAPTIVE, colors=16)
109
109
 
110
110
  # Init canvas config
111
111
  config = CanvasConfig()
@@ -127,9 +127,7 @@ async def chafa_convert_py(
127
127
  config.height = max(1, int(cols / data.size[0] * data.size[1] * px / py))
128
128
 
129
129
  # Set the foreground color
130
- if not (fg := datum.fg) and hasattr(app, "color_palette"):
131
- fg = app.color_palette.fg.base_hex
132
- if fg and (color := fg.lstrip("#")):
130
+ if (fg := datum.fg) and (color := fg.lstrip("#")):
133
131
  config.fg_color = (
134
132
  int(color[0:2], 16),
135
133
  int(color[2:4], 16),
@@ -137,9 +135,7 @@ async def chafa_convert_py(
137
135
  )
138
136
 
139
137
  # Set the background color
140
- if not (bg := datum.bg) and hasattr(app, "color_palette"):
141
- bg = app.color_palette.bg.base_hex
142
- if bg and (color := bg.lstrip("#")):
138
+ if (bg := datum.bg) and (color := bg.lstrip("#")):
143
139
  config.bg_color = (
144
140
  int(color[0:2], 16),
145
141
  int(color[2:4], 16),
@@ -55,11 +55,11 @@ async def html_to_ft(
55
55
  return await html._render(cols, rows)
56
56
 
57
57
 
58
- _BLACKLISTED_LEXERS = {
59
- "CBM BASIC V2",
60
- "Tera Term macro",
61
- "Text only",
62
- "GDScript",
58
+ _WHITELISTED_LEXERS = {
59
+ "python",
60
+ "markdown",
61
+ "javascript",
62
+ "json",
63
63
  }
64
64
 
65
65
 
@@ -72,6 +72,7 @@ async def ansi_to_ft(
72
72
  cols: int | None = None,
73
73
  rows: int | None = None,
74
74
  extend: bool = True,
75
+ lex: bool = False,
75
76
  ) -> StyleAndTextTuples:
76
77
  """Convert ANSI text to formatted text, lexing & formatting automatically."""
77
78
  data = datum.data
@@ -84,8 +85,10 @@ async def ansi_to_ft(
84
85
  markup = markup.expandtabs()
85
86
  # Use lexer whitelist
86
87
  if (
87
- lexer := detect_lexer(markup, path=datum.path)
88
- ) is not None and lexer.name not in _BLACKLISTED_LEXERS:
88
+ lex
89
+ and (lexer := detect_lexer(markup, path=datum.path)) is not None
90
+ and lexer.name in _WHITELISTED_LEXERS
91
+ ):
89
92
  from prompt_toolkit.lexers.pygments import _token_cache
90
93
 
91
94
  log.debug('Lexing output using "%s" lexer', lexer.name)
@@ -73,7 +73,7 @@ async def latex_to_png_dvipng(
73
73
  "-T",
74
74
  "tight",
75
75
  "-D",
76
- "150",
76
+ "175",
77
77
  "-z",
78
78
  "9",
79
79
  "-bg",
@@ -122,14 +122,15 @@ async def latex_to_png_py_mpl(
122
122
  from matplotlib.backends import backend_agg
123
123
 
124
124
  # mpl mathtext doesn't support display math, force inline
125
- data = datum.data.replace("$$", "$")
126
-
125
+ data = datum.data.strip().replace("$$", "$")
126
+ if not data.startswith("$"):
127
+ data = f"${data}$"
127
128
  buffer = BytesIO()
128
129
  prop = font_manager.FontProperties(size=12)
129
130
  parser = mathtext.MathTextParser("path")
130
131
  width, height, depth, _, _ = parser.parse(data, dpi=72, prop=prop)
131
- fig = figure.Figure(figsize=(width or 256 / 72, height or 256 / 72))
132
- fig.text(0, depth / height, data, fontproperties=prop, color=datum.fg)
132
+ fig = figure.Figure(figsize=(width / 72, height / 72))
133
+ fig.text(0, depth / height, data, fontproperties=prop, color=datum.fg, usetex=False)
133
134
  backend_agg.FigureCanvasAgg(fig)
134
135
  fig.savefig(buffer, dpi=120, format="png", transparent=True)
135
136
  return buffer.getvalue()
@@ -138,7 +139,7 @@ async def latex_to_png_py_mpl(
138
139
  register(
139
140
  from_=("svg", "jpeg", "pdf", "gif"),
140
141
  to="png",
141
- filter_=commands_exist("convert", "magick"),
142
+ filter_=commands_exist("convert", "mogrify"),
142
143
  )(partial(imagemagick_convert, "PNG"))
143
144
 
144
145
 
@@ -59,7 +59,7 @@ async def png_to_sixel_img2sixel(
59
59
  register(
60
60
  from_=("png", "jpeg", "svg", "pdf"),
61
61
  to="sixel",
62
- filter_=commands_exist("convert", "magick"),
62
+ filter_=commands_exist("convert", "mogrify"),
63
63
  )(partial(imagemagick_convert, "sixel"))
64
64
 
65
65
 
@@ -0,0 +1,28 @@
1
+ """Contain function which convert data to SVG format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from euporie.core.convert.registry import register
8
+ from euporie.core.convert.utils import have_modules
9
+
10
+ if TYPE_CHECKING:
11
+ from euporie.core.convert.datum import Datum
12
+
13
+
14
+ @register(from_="latex", to="svg", filter_=have_modules("ziamath"))
15
+ async def latex_to_svg_py_ziamath(
16
+ datum: Datum,
17
+ cols: int | None = None,
18
+ rows: int | None = None,
19
+ extend: bool = True,
20
+ ) -> str:
21
+ """Convert LaTeX to SVG using :py:mod:`ziamath`."""
22
+ import ziamath as zm
23
+
24
+ data = datum.data.strip()
25
+ if not data.startswith("$"):
26
+ data = f"$${data}$$"
27
+ latex = zm.Text(data, color=datum.fg, size=12)
28
+ return latex.svg()
@@ -10,9 +10,6 @@ from typing import TYPE_CHECKING
10
10
  from upath import UPath
11
11
  from upath.implementations.http import HTTPPath
12
12
 
13
- # from euporie.core.cache import cache
14
- from euporie.core.path import DataPath
15
-
16
13
  if TYPE_CHECKING:
17
14
  from pathlib import Path
18
15
 
@@ -50,11 +47,11 @@ def get_mime(path: Path | str) -> str | None:
50
47
  mime = None
51
48
 
52
49
  # Read from path of data URI
53
- if isinstance(path, DataPath):
54
- mime = path._mime
50
+ if path.exists() and isinstance(stat := path.stat(), dict):
51
+ mime = stat.get("mimetype")
55
52
 
56
- # If we have a web-address, nsure we have a url
57
- # Check http-headers and nsure we have a url
53
+ # If we have a web-address, ensure we have a url
54
+ # Check http-headers and ensure we have a url
58
55
  if not mime and isinstance(path, HTTPPath) and path._url is not None:
59
56
  from fsspec.asyn import sync
60
57
 
@@ -1,5 +1,7 @@
1
1
  """Contain commonly used data structures."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from functools import lru_cache
4
6
  from typing import NamedTuple
5
7
 
@@ -7,13 +9,13 @@ from typing import NamedTuple
7
9
  class DiBool(NamedTuple):
8
10
  """A tuple of four bools with directions."""
9
11
 
10
- top: "bool" = False
11
- right: "bool" = False
12
- bottom: "bool" = False
13
- left: "bool" = False
12
+ top: bool = False
13
+ right: bool = False
14
+ bottom: bool = False
15
+ left: bool = False
14
16
 
15
17
  @classmethod
16
- def from_value(cls, value: "bool") -> "DiBool":
18
+ def from_value(cls, value: bool) -> DiBool:
17
19
  """Construct an instance from a single value."""
18
20
  return cls(top=value, right=value, bottom=value, left=value)
19
21
 
@@ -21,13 +23,13 @@ class DiBool(NamedTuple):
21
23
  class DiInt(NamedTuple):
22
24
  """A tuple of four integers with directions."""
23
25
 
24
- top: "int" = 0
25
- right: "int" = 0
26
- bottom: "int" = 0
27
- left: "int" = 0
26
+ top: int = 0
27
+ right: int = 0
28
+ bottom: int = 0
29
+ left: int = 0
28
30
 
29
31
  @classmethod
30
- def from_value(cls, value: "int") -> "DiInt":
32
+ def from_value(cls, value: int) -> DiInt:
31
33
  """Construct an instance from a single value."""
32
34
  return cls(top=value, right=value, bottom=value, left=value)
33
35
 
@@ -35,13 +37,13 @@ class DiInt(NamedTuple):
35
37
  class DiStr(NamedTuple):
36
38
  """A tuple of four strings with directions."""
37
39
 
38
- top: "str" = ""
39
- right: "str" = ""
40
- bottom: "str" = ""
41
- left: "str" = ""
40
+ top: str = ""
41
+ right: str = ""
42
+ bottom: str = ""
43
+ left: str = ""
42
44
 
43
45
  @classmethod
44
- def from_value(cls, value: "str") -> "DiStr":
46
+ def from_value(cls, value: str) -> DiStr:
45
47
  """Construct an instance from a single value."""
46
48
  return cls(top=value, right=value, bottom=value, left=value)
47
49
 
@@ -49,22 +51,22 @@ class DiStr(NamedTuple):
49
51
  class WeightedInt(NamedTuple):
50
52
  """Ainterger with an associated weight."""
51
53
 
52
- weight: "int"
53
- value: "int"
54
+ weight: int
55
+ value: int
54
56
 
55
57
 
56
58
  class WeightedDiInt(NamedTuple):
57
59
  """A tuple of four weighted integers."""
58
60
 
59
- top: "WeightedInt"
60
- right: "WeightedInt"
61
- bottom: "WeightedInt"
62
- left: "WeightedInt"
61
+ top: WeightedInt
62
+ right: WeightedInt
63
+ bottom: WeightedInt
64
+ left: WeightedInt
63
65
 
64
66
  # We cannot use :py:func:`functools.cached_property` here as it does not work with
65
67
  # :py:Class:`NamedTuple`s.
66
68
  @property # type: ignore
67
69
  @lru_cache(maxsize=1) # noqa: B019
68
- def unweighted(self) -> "DiInt":
70
+ def unweighted(self) -> DiInt:
69
71
  """Get the padding without weights."""
70
72
  return DiInt(*(x.value for x in self))
euporie/core/filters.py CHANGED
@@ -24,6 +24,18 @@ if TYPE_CHECKING:
24
24
  from prompt_toolkit.layout.containers import Window
25
25
 
26
26
 
27
+ @Condition
28
+ @lru_cache
29
+ def have_ruff() -> bool:
30
+ """Determine if ruff is available."""
31
+ try:
32
+ import ruff # noqa F401
33
+ except ModuleNotFoundError:
34
+ return False
35
+ else:
36
+ return True
37
+
38
+
27
39
  @Condition
28
40
  @lru_cache
29
41
  def have_black() -> bool:
@@ -61,10 +73,12 @@ def have_ssort() -> bool:
61
73
 
62
74
 
63
75
  # Determine if we have at least one formatter
64
- have_formatter = have_black | have_isort | have_ssort
76
+ have_formatter = have_black | have_isort | have_ssort | have_ruff
65
77
 
66
- # Determine if euporie is running inside tmux.
78
+ # Determine if euporie is running inside a multiplexer.
79
+ in_screen = to_filter(os.environ.get("TERM", "").startswith("screen"))
67
80
  in_tmux = to_filter(os.environ.get("TMUX") is not None)
81
+ in_mplex = in_tmux | in_screen
68
82
 
69
83
 
70
84
  @Condition
euporie/core/format.py CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  import logging
6
7
  from typing import TYPE_CHECKING
7
8
 
9
+ from euporie.core.filters import have_black, have_isort, have_ruff, have_ssort
8
10
  from euporie.core.log import stdout_to_log
9
11
 
10
12
  if TYPE_CHECKING:
@@ -13,6 +15,27 @@ if TYPE_CHECKING:
13
15
  log = logging.getLogger(__name__)
14
16
 
15
17
 
18
+ def format_ruff(text: str) -> str:
19
+ """Format a code string using :py:mod:`ruff`."""
20
+ from ruff.__main__ import find_ruff_bin
21
+
22
+ try:
23
+ ruff_path = find_ruff_bin()
24
+ except FileNotFoundError:
25
+ pass
26
+ else:
27
+ import subprocess
28
+
29
+ with contextlib.suppress(subprocess.CalledProcessError):
30
+ text = subprocess.check_output(
31
+ [ruff_path, "format", "-"],
32
+ input=text,
33
+ text=True,
34
+ stderr=subprocess.DEVNULL,
35
+ )
36
+ return text
37
+
38
+
16
39
  def format_black(text: str) -> str:
17
40
  """Format a code string using :py:mod:`black`."""
18
41
  try:
@@ -22,7 +45,7 @@ def format_black(text: str) -> str:
22
45
  else:
23
46
  try:
24
47
  text = black.format_str(text, mode=black.Mode()).rstrip()
25
- except black.parsing.InvalidInput:
48
+ except (black.parsing.InvalidInput, KeyError):
26
49
  log.warning("Error formatting code with black: invalid input")
27
50
  return text
28
51
 
@@ -60,10 +83,13 @@ def format_ssort(text: str) -> str:
60
83
 
61
84
  def format_code(text: str, config: Config) -> str:
62
85
  """Format a code string using :py:mod:``."""
63
- if config.format_ssort:
86
+ formatters = set(config.formatters)
87
+ if have_ssort() and "ssort" in formatters:
64
88
  text = format_ssort(text)
65
- if config.format_isort:
89
+ if have_isort() and "isort" in formatters:
66
90
  text = format_isort(text)
67
- if config.format_black:
91
+ if have_black() and "black" in formatters:
68
92
  text = format_black(text)
93
+ if have_ruff() and "ruff" in formatters:
94
+ text = format_ruff(text)
69
95
  return text.strip()
euporie/core/ft/ansi.py CHANGED
@@ -73,7 +73,8 @@ class ANSI(PTANSI):
73
73
  # Check for backspace
74
74
  elif char == "\x08":
75
75
  # TODO - remove last character from last non-ZeroWidthEscape fragment
76
- formatted_text.pop()
76
+ if formatted_text:
77
+ formatted_text.pop()
77
78
  continue
78
79
 
79
80
  elif char in ("\x1b", "\x9b"):