File: _base.py

package info (click to toggle)
textual-image 0.8.5-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 2,468 kB
  • sloc: python: 1,851; makefile: 2
file content (154 lines) | stat: -rw-r--r-- 5,895 bytes parent folder | download
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))