textual-image 0.5.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.
- textual_image/__init__.py +4 -0
- textual_image/__main__.py +30 -0
- textual_image/_geometry.py +133 -0
- textual_image/_pixeldata.py +134 -0
- textual_image/_sixel.py +67 -0
- textual_image/_terminal.py +132 -0
- textual_image/_utils.py +44 -0
- textual_image-0.5.0.dist-info/METADATA +236 -0
- textual_image-0.5.0.dist-info/RECORD +11 -0
- textual_image-0.5.0.dist-info/WHEEL +5 -0
- textual_image-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
"""Run the textual_image demo."""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from argparse import ArgumentParser
|
|
7
|
+
from importlib.util import find_spec
|
|
8
|
+
|
|
9
|
+
from textual_image.demo.renderable import RENDERING_METHODS
|
|
10
|
+
|
|
11
|
+
textual_available = bool(find_spec("textual"))
|
|
12
|
+
default_mode = "rich" if find_spec("textual") is None else "textual"
|
|
13
|
+
|
|
14
|
+
parser = ArgumentParser(description="Demo the capabilities of textual-image")
|
|
15
|
+
parser.add_argument("mode", choices=["rich", "textual"], nargs="?", default=default_mode)
|
|
16
|
+
parser.add_argument("-m", "--method", choices=RENDERING_METHODS.keys(), default="auto")
|
|
17
|
+
arguments = parser.parse_args()
|
|
18
|
+
|
|
19
|
+
if arguments.mode == "rich":
|
|
20
|
+
from textual_image.demo.renderable import run as run_rich_demo
|
|
21
|
+
|
|
22
|
+
run_rich_demo(arguments.method)
|
|
23
|
+
elif not textual_available:
|
|
24
|
+
sys.stderr.write(
|
|
25
|
+
"Optional Textual dependency not available. Install this package as `textual-image[textual]` for Textual support."
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
from textual_image.demo.widget import run as run_textual_demo
|
|
29
|
+
|
|
30
|
+
run_textual_demo(arguments.method)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Geometry types and logic."""
|
|
2
|
+
|
|
3
|
+
from types import NoneType
|
|
4
|
+
from typing import NamedTuple, Tuple, cast
|
|
5
|
+
|
|
6
|
+
from textual_image._terminal import TerminalSizes
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ImageSize(NamedTuple):
|
|
10
|
+
"""Image size class capable of parsing size specifications and calculating pixel and cell sizes.
|
|
11
|
+
|
|
12
|
+
This class expects the size of the source image as `source_pixel_width` and `source_pixel_height`
|
|
13
|
+
and a target width and height as `width` and `height`.
|
|
14
|
+
`width` and `height` can have multiple forms:
|
|
15
|
+
|
|
16
|
+
- If set to an integer, they describe the number of cells to be used when rendering the image.
|
|
17
|
+
- If set to a string ending with `%` (e.g. `80%`) they describe the percentage of available render
|
|
18
|
+
space to be used when rending the image.
|
|
19
|
+
- If set to `auto` the image will be scaled to use as much as possible of the available render space
|
|
20
|
+
while keeping the image's aspect ratio.
|
|
21
|
+
- If set to `None` the original images dimension will be used.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
source_pixel_width: int
|
|
25
|
+
source_pixel_height: int
|
|
26
|
+
width: int | str | None
|
|
27
|
+
height: int | str | None
|
|
28
|
+
|
|
29
|
+
def validate(self) -> None:
|
|
30
|
+
"""Validate the instance's size specifications.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If `width` or `height` are not valid.
|
|
34
|
+
"""
|
|
35
|
+
self.validate_value(self.width)
|
|
36
|
+
self.validate_value(self.height)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def validate_value(cls, value: int | str | None) -> None:
|
|
40
|
+
"""Validate a single size specification.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
value: The size specification.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: If `width` or `height` are not valid.
|
|
47
|
+
"""
|
|
48
|
+
if isinstance(value, (int, NoneType)) or value == "auto":
|
|
49
|
+
return
|
|
50
|
+
if isinstance(value, str) and value.endswith("%") and value[:-1].isdecimal() and int(value[:-1]) >= 0:
|
|
51
|
+
return
|
|
52
|
+
raise ValueError(f"Invalid size: {value}")
|
|
53
|
+
|
|
54
|
+
def get_cell_size(self, max_width: int, max_height: int, terminal_sizes: TerminalSizes) -> Tuple[int, int]:
|
|
55
|
+
"""Calculates the render size based on original image size, size specification and terminal cell sizes.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
max_width: The maximum available render width in cells.
|
|
59
|
+
max_height: The maximum available render height in cells.
|
|
60
|
+
terminal_sizes: Size information of the terminal.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The render size in cells as tuple of (width, height).
|
|
64
|
+
"""
|
|
65
|
+
self.validate()
|
|
66
|
+
|
|
67
|
+
if self.source_pixel_height == 0 or self.source_pixel_width == 0:
|
|
68
|
+
return 0, 0
|
|
69
|
+
|
|
70
|
+
width = None
|
|
71
|
+
height = None
|
|
72
|
+
|
|
73
|
+
# Just use the original image size for dimensions that're `None`
|
|
74
|
+
if self.width is None:
|
|
75
|
+
width = round(self.source_pixel_width / terminal_sizes.cell_width)
|
|
76
|
+
if self.height is None:
|
|
77
|
+
height = round(self.source_pixel_height / terminal_sizes.cell_height)
|
|
78
|
+
|
|
79
|
+
# Explicitly given cell sizes
|
|
80
|
+
if isinstance(self.width, int):
|
|
81
|
+
width = self.width
|
|
82
|
+
if isinstance(self.height, int):
|
|
83
|
+
height = self.height
|
|
84
|
+
|
|
85
|
+
# Percentage of available space
|
|
86
|
+
if isinstance(self.width, str) and self.width.endswith("%"):
|
|
87
|
+
width = round(max_width / 100 * int(self.width[:-1]))
|
|
88
|
+
if isinstance(self.height, str) and self.height.endswith("%"):
|
|
89
|
+
height = round(max_height / 100 * int(self.height[:-1]))
|
|
90
|
+
|
|
91
|
+
# If `width` is `auto` we use the `max_width` if we don't have a height specified,
|
|
92
|
+
# but keep the ratio if `height` was set
|
|
93
|
+
if self.width == "auto" and self.height is not None:
|
|
94
|
+
ratio = self.source_pixel_width / self.source_pixel_height
|
|
95
|
+
scaled_pixel_height: float = (height or max_height) * terminal_sizes.cell_height
|
|
96
|
+
scaled_pixel_width = scaled_pixel_height * ratio
|
|
97
|
+
width = min(round(scaled_pixel_width / terminal_sizes.cell_width), max_width)
|
|
98
|
+
elif self.width == "auto":
|
|
99
|
+
width = max_width
|
|
100
|
+
|
|
101
|
+
# Same for height
|
|
102
|
+
if self.height == "auto" and self.width is not None:
|
|
103
|
+
ratio = self.source_pixel_width / self.source_pixel_height
|
|
104
|
+
scaled_pixel_width = cast(int, width) * terminal_sizes.cell_width
|
|
105
|
+
scaled_pixel_height = scaled_pixel_width / ratio
|
|
106
|
+
height = round(scaled_pixel_height / terminal_sizes.cell_height)
|
|
107
|
+
elif self.height == "auto":
|
|
108
|
+
height = max_height
|
|
109
|
+
|
|
110
|
+
# By deriving `height` from `width` in the `auto` case, `height` may exceed `max_height`,
|
|
111
|
+
# which shouldn't happen when set to `auto`. Fix this my scaling down both `height` and `width`.`
|
|
112
|
+
if self.width == "auto" and self.height == "auto" and cast(int, height) > max_height:
|
|
113
|
+
height = max_height
|
|
114
|
+
ratio = self.source_pixel_width / self.source_pixel_height
|
|
115
|
+
scaled_pixel_height = height * terminal_sizes.cell_height
|
|
116
|
+
scaled_pixel_width = scaled_pixel_height * ratio
|
|
117
|
+
width = round(scaled_pixel_width / terminal_sizes.cell_width)
|
|
118
|
+
|
|
119
|
+
return cast(Tuple[int, int], (width, height))
|
|
120
|
+
|
|
121
|
+
def get_pixel_size(self, max_width: int, max_height: int, terminal_sizes: TerminalSizes) -> Tuple[int, int]:
|
|
122
|
+
"""Calculates the pixel size to use for rendering based on original image size, size specification and terminal cell sizes.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
max_width: The maximum available render width in cells.
|
|
126
|
+
max_height: The maximum available render height in cells.
|
|
127
|
+
terminal_sizes: Size information of the terminal.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The render size in pixels as tuple of (width, height).
|
|
131
|
+
"""
|
|
132
|
+
width, height = self.get_cell_size(max_width, max_height, terminal_sizes)
|
|
133
|
+
return width * terminal_sizes.cell_width, height * terminal_sizes.cell_height
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Provides access to pixel data."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
from contextlib import nullcontext
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ContextManager, Iterable, Iterator, Literal, Tuple
|
|
8
|
+
|
|
9
|
+
from PIL import Image as PILImage
|
|
10
|
+
|
|
11
|
+
from textual_image._utils import grouped
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_image(image: str | Path | PILImage.Image) -> ContextManager[PILImage.Image]:
|
|
15
|
+
"""Ensures value to be an `PIL.Image.Image`.
|
|
16
|
+
|
|
17
|
+
This function accepts either a str or `pathlib.Path` of a path to an image file or a `PIL.Image.Image` instance.
|
|
18
|
+
It returns a context manager that either just provides the passed `PIL.Image.Image` instance or opens the path
|
|
19
|
+
with `PIL` and provides the result.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
image: Path to an image file or `PIL.Image.Image` instance.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A context manager providing a `PIL.Image.Image` instance.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(image, PILImage.Image):
|
|
28
|
+
return nullcontext(image)
|
|
29
|
+
return PILImage.open(image)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PixelMeta:
|
|
33
|
+
"""Provides access to meta information of an image from a path or `PIL.Image.Image` instance."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, image: str | Path | PILImage.Image) -> None:
|
|
36
|
+
"""Initializes a PixelMeta.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
image: Path to an image file or `PIL.Image.Image` instance with the image data to render.
|
|
40
|
+
"""
|
|
41
|
+
with ensure_image(image) as opened_image:
|
|
42
|
+
self.width = opened_image.width
|
|
43
|
+
self.height = opened_image.height
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PixelData:
|
|
47
|
+
"""Provides access to pixel data from a path or `PIL.Image.Image` instance."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, image: str | Path | PILImage.Image, mode: Literal["grayscale", "rgb"] | None = None) -> None:
|
|
50
|
+
"""Initializes a PixelData.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
image: Path to an image file or `PIL.Image.Image` instance with the image data to render.
|
|
54
|
+
mode: Mode to convert the image into or `None` to keep the original mode.
|
|
55
|
+
"""
|
|
56
|
+
with ensure_image(image) as opened_image:
|
|
57
|
+
self._image = opened_image.copy()
|
|
58
|
+
|
|
59
|
+
if mode == "grayscale":
|
|
60
|
+
self._image = self._image.convert("L")
|
|
61
|
+
elif mode == "rgb":
|
|
62
|
+
self._image = self._image.convert("RGB")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def pil_image(self) -> PILImage.Image:
|
|
66
|
+
"""The image as `PILImage.Image`."""
|
|
67
|
+
return self._image
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def width(self) -> int:
|
|
71
|
+
"""The image's width."""
|
|
72
|
+
return self._image.width
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def height(self) -> int:
|
|
76
|
+
"""The image's height."""
|
|
77
|
+
return self._image.height
|
|
78
|
+
|
|
79
|
+
def scaled(self, width: int, height: int) -> "PixelData":
|
|
80
|
+
"""Returns a scaled copy of this PixelData.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
width: Width to scale to.
|
|
84
|
+
height: Height to scale to.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A new `PixelData` instance of the same image, scaled to (width, height).
|
|
88
|
+
"""
|
|
89
|
+
scaled_image = self._image.resize((width, height))
|
|
90
|
+
return PixelData(scaled_image)
|
|
91
|
+
|
|
92
|
+
def cropped(self, left: int, top: int, right: int, bottom: int) -> "PixelData":
|
|
93
|
+
"""Returns a cropped copy of this PixelData.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
left: Left position of the crop rectangle.
|
|
97
|
+
top: Top position of the crop rectangle.
|
|
98
|
+
right: Right position of the crop rectangle.
|
|
99
|
+
bottom: Bottom position of the crop rectangle.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A new `PixelData` instance of the same image, cropped (left, top, right, bottom).
|
|
103
|
+
"""
|
|
104
|
+
cropped_image = self._image.crop((left, top, right, bottom))
|
|
105
|
+
return PixelData(cropped_image)
|
|
106
|
+
|
|
107
|
+
def to_base64(self) -> str:
|
|
108
|
+
"""Return the pixel data as base64 encoded PNG.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The image data, as base64 encoded PNG.
|
|
112
|
+
"""
|
|
113
|
+
image_buffer = io.BytesIO()
|
|
114
|
+
self._image.save(image_buffer, format="png", compress_level=2)
|
|
115
|
+
return b64encode(image_buffer.getvalue()).decode("ascii")
|
|
116
|
+
|
|
117
|
+
def __iter__(self) -> Iterator[Iterable[int | Tuple[int, ...]]]:
|
|
118
|
+
"""Iterate the pixel data.
|
|
119
|
+
|
|
120
|
+
Returns an `Iterator` over rows of the image.
|
|
121
|
+
The rows are `Iterable`s of pixel values.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
Iterate the image data::
|
|
125
|
+
|
|
126
|
+
for row in PixelData("image.png"):
|
|
127
|
+
for pixel in row:
|
|
128
|
+
... # Do something with the pixel here
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
An `Iterator` over `Iterable`s of pixel values.
|
|
132
|
+
"""
|
|
133
|
+
data = self._image.getdata()
|
|
134
|
+
yield from grouped(data, self._image.width)
|
textual_image/_sixel.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from itertools import groupby
|
|
2
|
+
|
|
3
|
+
from PIL import Image as PILImage
|
|
4
|
+
|
|
5
|
+
from textual_image._utils import grouped
|
|
6
|
+
|
|
7
|
+
DCS = "\x1bP"
|
|
8
|
+
ST = "\x1b\\"
|
|
9
|
+
COLORS = 256
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def image_to_sixels(image: PILImage.Image) -> str:
|
|
13
|
+
"""Convert an image to Sixels.
|
|
14
|
+
|
|
15
|
+
This function convert a `PIL.Image.Image` into the Sixel format.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
image: Image to convert to Sixels
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The `image` as Sixel data.
|
|
22
|
+
"""
|
|
23
|
+
image = image.convert("RGB").convert("P", palette=PILImage.Palette.ADAPTIVE, colors=COLORS)
|
|
24
|
+
|
|
25
|
+
return "".join(
|
|
26
|
+
[
|
|
27
|
+
_get_header(image),
|
|
28
|
+
_get_body(image),
|
|
29
|
+
_get_terminator(),
|
|
30
|
+
]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_header(image: PILImage.Image) -> str:
|
|
35
|
+
# See <https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1> for details about the parameters.
|
|
36
|
+
sixel_mode = f"{DCS}0;0;0"
|
|
37
|
+
raster_attributes = f'q"1;1;{image.width};{image.height}'
|
|
38
|
+
|
|
39
|
+
color_registers = []
|
|
40
|
+
for i, color in enumerate(grouped(image.getpalette() or [], 3)):
|
|
41
|
+
color_str = ";".join(str(int(channel / 256 * 100)) for channel in color)
|
|
42
|
+
color_registers.append(f"#{i};2;{color_str}")
|
|
43
|
+
|
|
44
|
+
return f'{sixel_mode}{raster_attributes}{"".join(color_registers)}'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_body(image: PILImage.Image) -> str:
|
|
48
|
+
tokens = []
|
|
49
|
+
|
|
50
|
+
for y, row in enumerate(grouped(image.getdata(), image.width)):
|
|
51
|
+
n = 1 << y % 6
|
|
52
|
+
for color, group in groupby(row):
|
|
53
|
+
tokens.append(f"#{color}")
|
|
54
|
+
|
|
55
|
+
count = len(list(group))
|
|
56
|
+
if count < 3:
|
|
57
|
+
tokens.append(chr(0x3F + n) * count)
|
|
58
|
+
else:
|
|
59
|
+
tokens.append(f"!{count}{chr(0x3F + n)}")
|
|
60
|
+
|
|
61
|
+
tokens.append("-" if n == 32 else "$")
|
|
62
|
+
|
|
63
|
+
return "".join(tokens)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_terminator() -> str:
|
|
67
|
+
return ST
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Functionality to interact with the terminal."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import termios
|
|
7
|
+
import tty
|
|
8
|
+
from array import array
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from fcntl import ioctl
|
|
11
|
+
from select import select
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
from typing import Iterator, NamedTuple
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TerminalError(Exception):
|
|
19
|
+
"""Error thrown on failing terminal operations."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TerminalSizes(NamedTuple):
|
|
25
|
+
"""Size of several terminal features."""
|
|
26
|
+
|
|
27
|
+
rows: int
|
|
28
|
+
"""Width of the terminal in cells."""
|
|
29
|
+
columns: int
|
|
30
|
+
"""Height of the terminal in cells."""
|
|
31
|
+
screen_width: int
|
|
32
|
+
"""Width of the terminal in pixels."""
|
|
33
|
+
screen_height: int
|
|
34
|
+
"""Height of the terminal in pixels."""
|
|
35
|
+
cell_width: int
|
|
36
|
+
"""Width of a terminal cell in pixels."""
|
|
37
|
+
cell_height: int
|
|
38
|
+
"""Height of a terminal cell in pixels."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_terminal_sizes() -> TerminalSizes:
|
|
42
|
+
"""Get size information from the terminal.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The size information
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
if not sys.__stdout__:
|
|
49
|
+
raise TerminalError("stdout is closed")
|
|
50
|
+
|
|
51
|
+
if sys.__stdout__.isatty():
|
|
52
|
+
try:
|
|
53
|
+
buf = array("H", [0, 0, 0, 0])
|
|
54
|
+
ioctl(sys.__stdout__, termios.TIOCGWINSZ, buf)
|
|
55
|
+
|
|
56
|
+
rows, columns, screen_width, screen_height = buf
|
|
57
|
+
|
|
58
|
+
# If we can't get the cell size (not all terminals support this) we're just assuming some hopefully reasonable numbers.
|
|
59
|
+
# Does this suck? Yes. Is there something we can do about it? Not really.
|
|
60
|
+
if not screen_width or not screen_height:
|
|
61
|
+
logger.warning("Terminal doesn't support getting pixel size; falling back to assumptions")
|
|
62
|
+
|
|
63
|
+
except OSError as e:
|
|
64
|
+
raise TerminalError("Unsupported terminal") from e
|
|
65
|
+
|
|
66
|
+
else:
|
|
67
|
+
logger.debug("Not connected to a terminal, assuming reasonable size information")
|
|
68
|
+
rows = 24
|
|
69
|
+
columns = 80
|
|
70
|
+
screen_width = 0
|
|
71
|
+
screen_height = 0
|
|
72
|
+
|
|
73
|
+
cell_width = int(screen_width / columns) or 8
|
|
74
|
+
cell_height = int(screen_height / rows) or 16
|
|
75
|
+
|
|
76
|
+
return TerminalSizes(
|
|
77
|
+
rows=rows,
|
|
78
|
+
columns=columns,
|
|
79
|
+
screen_width=screen_width,
|
|
80
|
+
screen_height=screen_height,
|
|
81
|
+
cell_width=cell_width,
|
|
82
|
+
cell_height=cell_height,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@contextmanager
|
|
87
|
+
def capture_terminal_response(
|
|
88
|
+
start_marker: str, end_marker: str, timeout: float | None = None
|
|
89
|
+
) -> Iterator[SimpleNamespace]:
|
|
90
|
+
"""Captures a terminal response.
|
|
91
|
+
|
|
92
|
+
Captures the terminal's response to an escape sequence.
|
|
93
|
+
This is a bit flaky -- keystrokes during reading the response can lead to false answers.
|
|
94
|
+
Additionally, when the terminal does *not* doesn't send an answer, the first character
|
|
95
|
+
of stdin may get lost as this function reads it to determine if it is the response.
|
|
96
|
+
Anyway, as this is improbable to happen, it should be fine.
|
|
97
|
+
|
|
98
|
+
Please not this function will not work anymore once Textual is started. Textual runs a threads to read stdin
|
|
99
|
+
and will grab the response.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
start_marker: The start sequence of the expected response
|
|
103
|
+
end_marker: The end sequence of the expected response
|
|
104
|
+
timeout: The number of seconds to wait for the response. None to disable timeout.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The terminal's response
|
|
108
|
+
"""
|
|
109
|
+
if not sys.__stdin__:
|
|
110
|
+
raise TerminalError("stdout is closed")
|
|
111
|
+
|
|
112
|
+
response = SimpleNamespace(sequence="")
|
|
113
|
+
|
|
114
|
+
stdin = sys.__stdin__.buffer.fileno()
|
|
115
|
+
old_term_mode = termios.tcgetattr(stdin)
|
|
116
|
+
tty.setcbreak(stdin, termios.TCSANOW)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
yield response
|
|
120
|
+
|
|
121
|
+
while not response.sequence.endswith(end_marker):
|
|
122
|
+
readable, _, _ = select([stdin], [], [], timeout)
|
|
123
|
+
if not readable:
|
|
124
|
+
raise TimeoutError("Timeout waiting for response")
|
|
125
|
+
|
|
126
|
+
response.sequence += os.read(stdin, 1).decode()
|
|
127
|
+
|
|
128
|
+
if not response.sequence.startswith(start_marker[: len(response.sequence)]):
|
|
129
|
+
raise TerminalError("Unexpected response from terminal")
|
|
130
|
+
|
|
131
|
+
finally:
|
|
132
|
+
termios.tcsetattr(stdin, termios.TCSANOW, old_term_mode)
|
textual_image/_utils.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Utility functions."""
|
|
2
|
+
|
|
3
|
+
from typing import Iterable, Iterator, TypeVar
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def grouped(iterable: Iterable[T], n: int) -> Iterator[Iterable[T]]:
|
|
9
|
+
"""Groups values of an iterable into equally sized groups.
|
|
10
|
+
|
|
11
|
+
The values of iterable are collected into groups of size `n`.
|
|
12
|
+
An iterator over the resulting groups is returned.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
Grouping a list of integers::
|
|
16
|
+
|
|
17
|
+
>>> list(grouped([1, 2, 3, 4, 5, 6], 2))
|
|
18
|
+
[(1, 2), (3, 4), (5, 6)]
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
iterable: Iterable over the values to group
|
|
22
|
+
n: Size of each group
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
An iterator over the groups
|
|
26
|
+
"""
|
|
27
|
+
return zip(*([iter(iterable)] * n), strict=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
N = TypeVar("N", bound=int | float)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def clamp(n: N, minimum: N, maximum: N) -> N:
|
|
34
|
+
"""Constrains a value to lie between two further values.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
n: The value to constrain.
|
|
38
|
+
minimum: The manimum value n may have.
|
|
39
|
+
maximum: The maximum value n may have.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The constrained value.
|
|
43
|
+
"""
|
|
44
|
+
return max(min(n, maximum), minimum)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: textual-image
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Render images via Kitty's Terminal Graphics Protocol with Rich and Textual
|
|
5
|
+
Author-email: Simon Hayessen <simon@lnqs.io>
|
|
6
|
+
Project-URL: Homepage, https://github.com/lnqs/textual-image
|
|
7
|
+
Project-URL: Documentation, https://github.com/lnqs/textual-image
|
|
8
|
+
Project-URL: Repository, https://github.com/lnqs/textual-image
|
|
9
|
+
Project-URL: Issues, https://github.com/lnqs/textual-image/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
15
|
+
Classifier: License :: OSI Approved :: GNU General Public License (GPL)
|
|
16
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: pillow>=10.3.0
|
|
25
|
+
Requires-Dist: rich>=13.4.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff; extra == "dev"
|
|
32
|
+
Requires-Dist: syrupy; extra == "dev"
|
|
33
|
+
Requires-Dist: tox; extra == "dev"
|
|
34
|
+
Requires-Dist: typing-extensions; extra == "dev"
|
|
35
|
+
Requires-Dist: typos; extra == "dev"
|
|
36
|
+
Provides-Extra: textual
|
|
37
|
+
Requires-Dist: textual>=0.68.0; extra == "textual"
|
|
38
|
+
|
|
39
|
+
# textual-image
|
|
40
|
+
|
|
41
|
+
**Render images directly in your terminal using [Textual](https://www.textualize.io/) and [Rich](https://github.com/Textualize/rich).**
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
_textual-image_ offers both Rich renderables and Textual Widgets that leverage the [Terminal Graphics Protocol (TGP)](https://sw.kovidgoyal.net/kitty/graphics-protocol/) and [Sixel](https://en.wikipedia.org/wiki/Sixel) protocols to display images in your terminal. For terminals that don't support these protocols, fallback rendering using Unicode characters is available.
|
|
46
|
+
|
|
47
|
+
## Supported Terminals
|
|
48
|
+
|
|
49
|
+
- **Terminal Graphics Protocol (TGP)**: Initially introduced by the [Kitty](https://sw.kovidgoyal.net/kitty/) terminal emulator, fully supported in Kitty, and largely implemented in [WezTerm](https://wezfurlong.org/wezterm/index.html) and partially supported by [Konsole](https://konsole.kde.org/) and [wayst](https://github.com/91861/wayst).
|
|
50
|
+
- **Sixel Graphics**: Supported by various terminal emulators including [xterm](https://invisible-island.net/xterm/) and others.
|
|
51
|
+
|
|
52
|
+
_Note_: Testing has been conducted primarily using Kitty for TGP and xterm for Sixel on Linux, with some sanity checks on other terminals. Feedback and interoperability testing on other terminal emulators and operating systems would be highly valued.
|
|
53
|
+
|
|
54
|
+
See the Support Matrix below on what was tested already.
|
|
55
|
+
|
|
56
|
+
### Support Matrix [^1]
|
|
57
|
+
|
|
58
|
+
| Terminal | TPG support | Sixel support | Works with textual-image |
|
|
59
|
+
|---------------------|:-----------:|:-------------:|:------------------------:|
|
|
60
|
+
| Alacritty | ❌ | ❌ | |
|
|
61
|
+
| Black Box | ❌ | ✅ | ⚫ |
|
|
62
|
+
| Bobcat | ❌ | ✅ | ⚫ |
|
|
63
|
+
| ConEmu | ❌ | ❌ | |
|
|
64
|
+
| Contour | ❌ | ✅ | ⚫ |
|
|
65
|
+
| ctx terminal | ❌ | ✅ | ⚫ |
|
|
66
|
+
| Darktile | ❌ | ✅ | ⚫ |
|
|
67
|
+
| DomTerm | ❌ | ✅ | ⚫ |
|
|
68
|
+
| Eat | ❌ | ✅ | ⚫ |
|
|
69
|
+
| Elementary Terminal | ❌ | ❌ | |
|
|
70
|
+
| foot | ❌ | ✅ | ✅ |
|
|
71
|
+
| GNOME Terminal | ❌ | ❌ | |
|
|
72
|
+
| guake | ❌ | ❌ | |
|
|
73
|
+
| iTerm2 | ❌ | ✅ | ⚫ |
|
|
74
|
+
| kitty | ✅ | ❌ | ✅ |
|
|
75
|
+
| konsole | ✅ | ✅ | ❓[^2] |
|
|
76
|
+
| LaTerminal | ❌ | ✅ | ⚫ |
|
|
77
|
+
| MacTerm | ❌ | ✅ | ⚫ |
|
|
78
|
+
| mintty | ❌ | ✅ | ⚫ |
|
|
79
|
+
| mlterm | ❌ | ✅ | ⚫ |
|
|
80
|
+
| MobaXterm | ❌ | ❌ | |
|
|
81
|
+
| PuTTY | ❌ | ❌ | |
|
|
82
|
+
| Rio terminal | ❌ | ❌ | |
|
|
83
|
+
| Rlogin | ❌ | ✅ | ⚫ |
|
|
84
|
+
| suckless st | ❌ | ❌ | |
|
|
85
|
+
| SwiftTerm | ❌ | ✅ | ⚫ |
|
|
86
|
+
| SyncTERM | ❌ | ✅ | ⚫ |
|
|
87
|
+
| TeraTerm | ❌ | ❌ | |
|
|
88
|
+
| Terminal.app | ❌ | ❌ | |
|
|
89
|
+
| Terminology | ❌ | ❌ | |
|
|
90
|
+
| termux | ❌ | ❌ | |
|
|
91
|
+
| Tilix | ❌ | ❌ | |
|
|
92
|
+
| tmux | ❌ | ✅ | ✅[^3] |
|
|
93
|
+
| toyterm | ❌ | ✅ | ⚫ |
|
|
94
|
+
| URxvt | ❌ | ❌ | |
|
|
95
|
+
| U++ | ❌ | ✅ | ⚫ |
|
|
96
|
+
| Visual Studio Code | ❌ | ✅ | ✅[^4] |
|
|
97
|
+
| wayst | ✅ | ✅ | ❓[^5] |
|
|
98
|
+
| wezterm | ✅ | ✅ | ❓[^6] |
|
|
99
|
+
| Windows Console | ❌ | ❌ | |
|
|
100
|
+
| Windows Terminal | ❌ | ✅ | ⚫ |
|
|
101
|
+
| xfce-terminal | ❌ | ✅ | ⚫ |
|
|
102
|
+
| xterm | ❌ | ✅ | ✅ |
|
|
103
|
+
| xterm.js | ❌ | ✅ | ⚫ |
|
|
104
|
+
| yaft | ❌ | ✅ | ⚫ |
|
|
105
|
+
| Yakuake | ❌ | ✅ | ⚫ |
|
|
106
|
+
| Zellij | ❌ | ✅ | ❌[^7] |
|
|
107
|
+
|
|
108
|
+
✅ = Supported; ❌ = Not Supported; ⚫ = To Be Tested; ❓ = Works, but with glitches (further investigation needed)
|
|
109
|
+
|
|
110
|
+
[^1]: Based on [Are We Sixel Yet?](https://www.arewesixelyet.com/)
|
|
111
|
+
[^2]: Reports to support TGP but doesn't draw images. If set to Sixel explicitly, it works besides a few minor glitches.
|
|
112
|
+
[^3]: Works only in a Sixel enabled terminal, TGP does not work with tmux.
|
|
113
|
+
[^4]: The `terminal.integrated.enableImages` setting has to be enabled.
|
|
114
|
+
[^5]: Both TGP and Sixel draw graphics, but only with major glitches.
|
|
115
|
+
[^6]: TGP draws graphics, but with major glitches; Sixel works fine but doesn't get auto-selected due to reporting TGP support.
|
|
116
|
+
[^7]: Reports to support Sixel, but doesn't draw anything.
|
|
117
|
+
|
|
118
|
+
### Enabling Sixel Support on xterm
|
|
119
|
+
|
|
120
|
+
Sixel on xterm is disabled by default. To enable it, add `+lc` and `-ti vt340` options when launching xterm:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
xterm +lc -ti vt340
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Alternatively, you can add these options to your xterm configuration file (`~/.Xresources` or `~/.Xdefaults`) to make the change permanent:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
echo 'XTerm*decTerminalID: vt340' >> ~/.Xresources
|
|
130
|
+
xrdb -merge ~/.Xresources
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Installation
|
|
134
|
+
|
|
135
|
+
Install _textual-image_ using pip with the following commands:
|
|
136
|
+
|
|
137
|
+
For the basic installation:
|
|
138
|
+
```sh
|
|
139
|
+
pip install textual-image
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
To include the Textual Widget's dependencies:
|
|
143
|
+
```sh
|
|
144
|
+
pip install textual-image[textual]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Demonstration
|
|
148
|
+
|
|
149
|
+
Once installed, run the demo application to see the module in action.
|
|
150
|
+
|
|
151
|
+
For a demonstration of the Rich renderable, use:
|
|
152
|
+
```sh
|
|
153
|
+
python -m textual_image rich
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
For a demonstration of the Textual Widget, use:
|
|
157
|
+
```sh
|
|
158
|
+
python -m textual_image textual
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The module will automatically select the best available rendering option. If you wish to specify a particular rendering method, use the `-p` argument with one of the following values: `tgp`, `sixel`, `halfcell`, or `unicode`.
|
|
162
|
+
|
|
163
|
+
For more information, use:
|
|
164
|
+
```sh
|
|
165
|
+
python -m textual_image --help
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Usage
|
|
169
|
+
|
|
170
|
+
### Rich Integration
|
|
171
|
+
|
|
172
|
+
To use the Rich renderable, simply pass an instance of `textual_image.renderable.Image` to a Rich function that renders data:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from rich.console import Console
|
|
176
|
+
from textual_image.renderable import Image
|
|
177
|
+
|
|
178
|
+
console = Console()
|
|
179
|
+
console.print(Image("path/to/image.png"))
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The `Image` constructor accepts either a string, a `pathlib.Path` representing the file path of an image readable by [Pillow](https://python-pillow.org/), or a Pillow `Image` instance directly.
|
|
183
|
+
|
|
184
|
+
By default, the image is rendered in its original dimensions. You can modify this behavior by specifying the `width` and/or `height` parameters. These can be defined as an integer (number of cells), a percentage string (e.g., `50%`), or the literal `auto` to automatically scale while maintaining the aspect ratio.
|
|
185
|
+
|
|
186
|
+
`textual_image.renderable.Image` defaults to the best available rendering method. To specify an explicit rendering method, use one of the following classes: `textual_image.renderable.tgp.Image`, `textual_image.renderable.sixel.Image`, `textual_image.renderable.halfcell.Image`, or `textual_image.renderable.unicode.Image`.
|
|
187
|
+
|
|
188
|
+
### Textual Integration
|
|
189
|
+
|
|
190
|
+
For integration with Textual, _textual-image_ offers a Textual `Widget` to render images:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from textual.app import App, ComposeResult
|
|
194
|
+
from textual_image.widget import Image
|
|
195
|
+
|
|
196
|
+
class ImageApp(App[None]):
|
|
197
|
+
def compose(self) -> ComposeResult:
|
|
198
|
+
yield Image("path/to/image.png")
|
|
199
|
+
|
|
200
|
+
ImageApp().run()
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The `Image` constructor accepts either a string or a `pathlib.Path` with the file path of an image readable by [Pillow](https://python-pillow.org/), or a Pillow `Image` instance directly.
|
|
204
|
+
|
|
205
|
+
You can also set the image using the `image` property of an `Image` instance:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from textual.app import App, ComposeResult
|
|
209
|
+
from textual_image.textual import Image
|
|
210
|
+
|
|
211
|
+
class ImageApp(App[None]):
|
|
212
|
+
def compose(self) -> ComposeResult:
|
|
213
|
+
image = Image()
|
|
214
|
+
image.image = "path/to/image.png"
|
|
215
|
+
yield image
|
|
216
|
+
|
|
217
|
+
ImageApp().run()
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
If a different image is set, the Widget will update to display the new image.
|
|
221
|
+
|
|
222
|
+
By default, the best available rendering option is used. To override this, you can instantiate `textual_image.widget.TGPImage`, `textual_image.widget.SixelImage`, `textual_image.widget.HalfcellImage`, or `textual_image.widget.UnicodeImage` directly.
|
|
223
|
+
|
|
224
|
+
_*Note*_: The process of determining the best available rendering option involves querying the terminal, which means sending and receiving data. Since Textual starts threads to handle input and output, this query will **not work** once the Textual app has started. Therefore, make sure that `textual_image.renderable` is imported **before** running the Textual app (which is typically the case in most use cases).
|
|
225
|
+
|
|
226
|
+
## Limitations
|
|
227
|
+
|
|
228
|
+
- **Sixel Support in Textual**: Sixel support in Textual is not particularly performant due to the way Textual handles rendering. The Sixel graphics are injected into the rendering process in a somewhat hacky manner, which affects performance. Scrolling and changing styles of images can lead to a lot of flickering. But for mostly static images it should work fine. If not, please file an issue on GitHub.
|
|
229
|
+
|
|
230
|
+
## Contribution
|
|
231
|
+
|
|
232
|
+
If you find this module useful, please consider starring the repository on GitHub.
|
|
233
|
+
|
|
234
|
+
This project began by moving some TGP functionality from a private project to a public GitHub repository and PyPI package, with some additional code added along the way to support Sixel graphics. If you encounter any issues, please file an issue on GitHub.
|
|
235
|
+
|
|
236
|
+
Contributions via pull requests are welcome and encouraged.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
textual_image/__init__.py,sha256=Z2sVPBEYASzL_HAPG0iYCko2XKxTtQ4-K6zmTCe3o3A,135
|
|
2
|
+
textual_image/__main__.py,sha256=68Q5b1U5cxlPvssfc91-ZzXYGhgjq2VfXeNPB1wle1w,1034
|
|
3
|
+
textual_image/_geometry.py,sha256=I6rDB6LBMBAypDzQ0613DNxnr4HXtMGUD1xL0QG0rOU,5808
|
|
4
|
+
textual_image/_pixeldata.py,sha256=YqOdRTJTKIfVEya82vugVmskrY8XKUKEGivADbAKJSU,4504
|
|
5
|
+
textual_image/_sixel.py,sha256=pLsYtR1af_7Ow5sVv6h2M08wSddzLzNqWt7ooSKC5jo,1750
|
|
6
|
+
textual_image/_terminal.py,sha256=zzxfdKT0h0uaS9t_cWM0rPxifjFDtwFb1wlwYEKOq_s,4122
|
|
7
|
+
textual_image/_utils.py,sha256=laffJ8rjenr9tHlcy05bSuJkHi5VzYAPn53OD18nVe0,1075
|
|
8
|
+
textual_image-0.5.0.dist-info/METADATA,sha256=fgSOxeOmJWqv7NGh75nSzleA030qB5vmvitDePplxzA,12684
|
|
9
|
+
textual_image-0.5.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
10
|
+
textual_image-0.5.0.dist-info/top_level.txt,sha256=MOt8XOuhYDHzY66-5tWpDc7ctvTVrcHPjGOa7E7YyYI,14
|
|
11
|
+
textual_image-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
textual_image
|