1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
|
"""Provides a Textual `Widget` to render images in the terminal."""
import io
from typing import IO, Literal, Tuple, Type, cast
from PIL import Image as PILImage
from textual.app import RenderResult
from textual.css.styles import RenderStyles
from textual.geometry import Size
from textual.widget import Widget
from typing_extensions import override
from textual_image._geometry import ImageSize
from textual_image._pixeldata import PixelMeta
from textual_image._terminal import get_cell_size
from textual_image._utils import StrOrBytesPath, is_non_seekable_stream
from textual_image.renderable._protocol import ImageRenderable
class Image(Widget):
"""Textual `Widget` to render images in the terminal."""
_Renderable: Type[ImageRenderable]
@override
def __init_subclass__(
cls,
Renderable: Type[ImageRenderable],
can_focus: bool | None = None,
can_focus_children: bool | None = None,
inherit_css: bool = True,
inherit_bindings: bool = True,
) -> None:
"""Initializes sub classes.
Args:
cls: The sub class to initialize.
Renderable: The image renderable the subclass is supposed to use.
can_focus: Is the Widget can become focussed.
can_focus_children: If the Widget's children can become focussed.
inherit_css: If CSS should be inherited.
inherit_bindings: If bindings should be inherited.
"""
super().__init_subclass__(can_focus, can_focus_children, inherit_css, inherit_bindings)
cls._Renderable = Renderable
def __init__(
self,
image: StrOrBytesPath | IO[bytes] | PILImage.Image | None = None,
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initializes the `Image`.
Args:
image: Path to an image file, a byte stream containing image data, or `PIL.Image.Image` instance with the
image data to render.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes for the widget.
disabled: Whether the widget is disabled or not.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._renderable: ImageRenderable | None = None
self._image: StrOrBytesPath | IO[bytes] | PILImage.Image | None = None
self._image_width: int = 0
self._image_height: int = 0
self.image = image
@property
def image(self) -> StrOrBytesPath | IO[bytes] | PILImage.Image | None:
"""The image to render.
Path to an image file or `PIL.Image.Image` instance with the image data to render.
"""
return self._image
@image.setter
def image(self, value: StrOrBytesPath | IO[bytes] | PILImage.Image | None) -> None:
if self._renderable:
self._renderable.cleanup()
self._renderable = None
if is_non_seekable_stream(value):
# If the value is a non-seekable stream, the data must be read into a seekable object.
# This is necessary for two reasons:
# 1. Multiple reads are required, which necessitates a seekable stream.
# 2. PIL.Image.open may fail to correctly detect the image format when provided with a non-seekable stream.
stream = io.BytesIO(cast(IO[bytes], value).read())
self._image = PILImage.open(stream)
else:
self._image = value
if self._image:
pixel_meta = PixelMeta(self._image)
self._image_width = pixel_meta.width
self._image_height = pixel_meta.height
else:
self._image_width = 0
self._image_height = 0
self.refresh(layout=True)
@override
def render(self) -> RenderResult:
if not self._image:
return ""
if self._renderable:
self._renderable.cleanup()
self._renderable = None
self._renderable = self._Renderable(self._image, *self._get_styled_size())
return self._renderable
@override
def get_content_width(self, container: Size, viewport: Size) -> int:
styled_width, styled_height = self._get_styled_size()
terminal_sizes = get_cell_size()
# If Textual doesn't know the container height yet it's reported as 0. To prevent our
# image to be size 0x0 if set to auto, pass a really high number in that case.
width, _ = ImageSize(
self._image_width, self._image_height, width=styled_width, height=styled_height
).get_cell_size(container.width, container.height or 2**32, terminal_sizes)
return width
@override
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
styled_width, styled_height = self._get_styled_size()
terminal_sizes = get_cell_size()
_, height = ImageSize(
self._image_width, self._image_height, width=styled_width, height=styled_height
).get_cell_size(width, container.height or 2**32, terminal_sizes)
return height
def _get_styled_size(self) -> Tuple[None | Literal["auto"] | int, None | Literal["auto"] | int]:
width = self._get_styled_dimension(self.styles, "width")
height = self._get_styled_dimension(self.styles, "height")
return width, height
def _get_styled_dimension(
self, styles: RenderStyles, dimension: Literal["width", "height"]
) -> None | Literal["auto"] | int:
style = getattr(styles, dimension)
if style is None:
return None
elif style.is_auto:
return "auto"
else:
return cast(int, getattr(self.content_size, dimension))
|