diff --git a/README.md b/README.md index ce89abe..9524014 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ _textual-image_ offers both Rich renderables and Textual Widgets that leverage t - **Terminal Graphics Protocol (TGP)**: Initially introduced by the [Kitty](https://sw.kovidgoyal.net/kitty/) terminal emulator. While support is partially available in other terminals, it doesn't seem to be really usable there. - **Sixel Graphics**: Supported by various terminal emulators including [xterm](https://invisible-island.net/xterm/) and a lot of others. +- **iTerm2 Inline Images Protocol**: Originally developed for [iTerm2](https://iterm2.com/), this protocol is now supported by several other terminal emulators for displaying inline images. _Note_: As implementation of these protocols differ a lot feedback on different terminal emulators is very welcome. @@ -23,21 +24,21 @@ See the Support Matrix below on what was tested already. [^1]: Based on [Are We Sixel Yet?](https://www.arewesixelyet.com/) -| Terminal | TGP support | Sixel support | Works with textual-image | -|---------------------|:-----------:|:-------------:|:------------------------:| -| Black Box | ❌ | ✅ | ✅ | -| foot | ❌ | ✅ | ✅ | -| GNOME Terminal | ❌ | ❌ | | -| iTerm2 | ❌ | ✅ | ✅ | -| kitty | ✅ | ❌ | ✅ | -| konsole | ✅ | ✅ | ✅ | -| tmux | ⚠️ | ✅ | ✅ | -| Visual Studio Code | ❌ | ✅ | ✅ | -| Warp | ❌ | ❌ | ❌ | -| wezterm | ✅ | ✅ | ✅ | -| Windows Console | ❌ | ❌ | | -| Windows Terminal | ❌ | ✅ | ✅ | -| xterm | ❌ | ✅ | ✅ | +| Terminal | TGP support | Sixel support | iTerm2 support | Works with textual-image | +|---------------------|:-----------:|:-------------:|:--------------:|:------------------------:| +| Black Box | ❌ | ✅ | ❌ | ✅ | +| foot | ❌ | ✅ | ✅ | ✅ | +| GNOME Terminal | ❌ | ❌ | ❌ | | +| iTerm2 | ❌ | ✅ | ✅ | ✅ | +| kitty | ✅ | ❌ | ❌ | ✅ | +| konsole | ✅ | ✅ | ✅ | ✅ | +| tmux | ⚠️ | ✅ | ⚠️ | ✅ | +| Visual Studio Code | ❌ | ✅ | ✅ | ✅ | +| Warp | ❌ | ❌ | ❌ | ❌ | +| wezterm | ✅ | ✅ | ✅ | ✅ | +| Windows Console | ❌ | ❌ | ❌ | | +| Windows Terminal | ❌ | ✅ | ❌ | ✅ | +| xterm | ❌ | ✅ | ❌ | ✅ | ✅ = Supported; ❌ = Not Supported; ⚠️ = Requires additional terminal/tmux configuration @@ -46,6 +47,7 @@ See the Support Matrix below on what was tested already. **Homepage**: https://gitlab.gnome.org/raggesilver/blackbox **TGP support**: No **Sixel support**: Yes +**iTerm2 support**: No **Works**: Yes **Notes**: @@ -59,6 +61,7 @@ This is the case for the Flatpak version of BlackBox, but not on most Linux dist **Homepage**: https://codeberg.org/dnkl/foot **TGP support**: No **Sixel support**: Yes +**iTerm2 support**: Yes **Works**: Yes **Notes:** @@ -69,6 +72,7 @@ Works out of the box, no known issues. **Homepage**: https://gitlab.gnome.org/GNOME/gnome-terminal **TGP support**: No **Sixel support**: No +**iTerm2 support**: No **Works**: No **Notes:** @@ -79,6 +83,7 @@ Relies on VTE Sixel implementation ( None: import textual_image.renderable - from textual_image.renderable import halfcell, sixel, tgp, unicode + from textual_image.renderable import halfcell, iterm2, sixel, tgp, unicode with patch("sys.__stdout__.isatty", return_value=True): - with patch("textual_image.renderable.tgp.query_terminal_support", return_value=True): + with patch("textual_image.renderable.iterm2.query_terminal_support", return_value=True): module = reload(textual_image.renderable) - assert module.Image is tgp.Image + assert module.Image is iterm2.Image - with patch("textual_image.renderable.tgp.query_terminal_support", return_value=False): + with patch("textual_image.renderable.iterm2.query_terminal_support", return_value=False): with patch("textual_image.renderable.sixel.query_terminal_support", return_value=True): module = reload(textual_image.renderable) assert module.Image is sixel.Image - with patch("textual_image.renderable.tgp.query_terminal_support", return_value=False): with patch("textual_image.renderable.sixel.query_terminal_support", return_value=False): - module = reload(textual_image.renderable) - assert module.Image is halfcell.Image + with patch("textual_image.renderable.tgp.query_terminal_support", return_value=True): + module = reload(textual_image.renderable) + assert module.Image is tgp.Image + + with patch("textual_image.renderable.tgp.query_terminal_support", return_value=False): + module = reload(textual_image.renderable) + assert module.Image is halfcell.Image with patch("sys.__stdout__.isatty", return_value=False): module = reload(textual_image.renderable) diff --git a/tests/renderable/test_iterm2.py b/tests/renderable/test_iterm2.py new file mode 100644 index 0000000..617a33b --- /dev/null +++ b/tests/renderable/test_iterm2.py @@ -0,0 +1,68 @@ +from unittest.mock import patch + +from rich.console import Console +from rich.measure import Measurement +from syrupy.assertion import SnapshotAssertion + +from tests.data import CONSOLE_OPTIONS, TEST_IMAGE +from tests.utils import render + + +def test_build_iterm2_sequence() -> None: + from textual_image.renderable.iterm2 import _build_iterm2_sequence + + seq = _build_iterm2_sequence("abc123", 100, 200) + assert seq.startswith("\x1b]1337;File=") + assert "width=100px" in seq + assert "height=200px" in seq + assert "inline=1" in seq + assert "preserveAspectRatio=0" in seq + assert ":abc123\x07" in seq + + +def test_render(snapshot: SnapshotAssertion) -> None: + from textual_image.renderable.iterm2 import Image + + renderable = Image(TEST_IMAGE, width=4) + assert render(renderable) == snapshot + + +def test_measure() -> None: + from textual_image.renderable.iterm2 import Image + + renderable = Image(TEST_IMAGE, width=4) + assert renderable.__rich_measure__(Console(), CONSOLE_OPTIONS) == Measurement(4, 4) + + +def test_cleanup() -> None: + from textual_image.renderable.iterm2 import Image + + Image(TEST_IMAGE, width=4).cleanup() + + +def test_query_terminal_support_no_stdout() -> None: + from textual_image.renderable.iterm2 import query_terminal_support + + with patch("sys.__stdout__", None): + assert not query_terminal_support() + + +def test_query_terminal_support_iterm2_env() -> None: + from textual_image.renderable.iterm2 import query_terminal_support + + with patch("sys.__stdout__"), patch.dict("os.environ", {"TERM_PROGRAM": "iTerm2"}): + assert query_terminal_support() + + +def test_query_terminal_support_wezterm_env() -> None: + from textual_image.renderable.iterm2 import query_terminal_support + + with patch("sys.__stdout__"), patch.dict("os.environ", {"TERM_PROGRAM": "WezTerm"}): + assert query_terminal_support() + + +def test_query_terminal_support_unknown_env() -> None: + from textual_image.renderable.iterm2 import query_terminal_support + + with patch("sys.__stdout__"), patch.dict("os.environ", {"TERM_PROGRAM": "xterm-256color"}): + assert not query_terminal_support() diff --git a/tests/widget/test_iterm2.py b/tests/widget/test_iterm2.py new file mode 100644 index 0000000..6c05eeb --- /dev/null +++ b/tests/widget/test_iterm2.py @@ -0,0 +1,182 @@ +from unittest import skipUnless +from unittest.mock import PropertyMock, patch + +from PIL import Image as PILImage +from PIL import ImageOps +from rich.console import Console +from rich.measure import Measurement +from rich.segment import Segment + +from tests.data import CONSOLE_OPTIONS, TEST_IMAGE, TEXTUAL_ENABLED +from tests.utils import load_non_seekable_bytes_io + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +def test_cached_iterm2_data_is_hit() -> None: + from textual.geometry import Region, Size + + from textual_image._terminal import CellSize + from textual_image.widget.iterm2 import _CachedITerm2Data + + image = TEST_IMAGE + crop = Region(0, 0, 10, 10) + size = Size(10, 10) + terminal_sizes = CellSize(10, 20) + + cached = _CachedITerm2Data(image, crop, size, terminal_sizes, "data") + assert cached.is_hit(image, crop, size, terminal_sizes) + assert not cached.is_hit(image, Region(1, 0, 10, 10), size, terminal_sizes) + assert not cached.is_hit(image, crop, Size(5, 5), terminal_sizes) + assert not cached.is_hit(image, crop, size, CellSize(8, 16)) + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +def test_noop_renderable() -> None: + from textual_image.widget.iterm2 import _NoopRenderable + + r = _NoopRenderable(TEST_IMAGE) + assert r.__rich_measure__(Console(), CONSOLE_OPTIONS) == Measurement(0, 0) + segments = list(r.__rich_console__(Console(), CONSOLE_OPTIONS)) + assert segments == [Segment("")] + r.cleanup() + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +async def test_app() -> None: + from textual.app import App, ComposeResult + + from textual_image.widget.iterm2 import Image + + class TestApp(App[None]): + CSS = """ + .auto { width: auto; height: auto; } + .fixed { width: 10; height: 10; } + """ + + def compose(self) -> ComposeResult: + yield Image(TEST_IMAGE) + yield Image(TEST_IMAGE, classes="auto") + yield Image(TEST_IMAGE, classes="fixed") + yield Image() + + app = TestApp() + + async with app.run_test() as pilot: + with PILImage.open(TEST_IMAGE) as test_image: + app.query_one(Image).image = ImageOps.flip(test_image) + assert app.query_one(Image).image != TEST_IMAGE + await pilot.pause() + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +async def test_unseekable_stream() -> None: + from textual.app import App, ComposeResult + + from textual_image.widget.iterm2 import Image + + image = Image(load_non_seekable_bytes_io(TEST_IMAGE)) + + class TestApp(App[None]): + def compose(self) -> ComposeResult: + yield image + + app = TestApp() + async with app.run_test() as pilot: + await pilot.pause() + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +async def test_handling_no_screen_on_render() -> None: + from textual.app import App, ComposeResult + from textual.dom import NoScreen + from textual.geometry import Region + + from textual_image.widget.iterm2 import Image, _ImageITerm2Impl + + class TestApp(App[None]): + def compose(self) -> ComposeResult: + yield Image(TEST_IMAGE) + + app = TestApp() + + async with app.run_test(): + impl = app.query_one(_ImageITerm2Impl) + + result = impl.render_lines(Region(0, 0, 10, 10)) + assert result + + with patch.object(_ImageITerm2Impl, "screen", PropertyMock(side_effect=NoScreen)): + result = impl.render_lines(Region(0, 0, 10, 10)) + assert not result + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +async def test_render_lines_no_image_returns_empty() -> None: + from textual.app import App, ComposeResult + from textual.geometry import Region + + from textual_image.widget.iterm2 import Image, _ImageITerm2Impl + + class TestApp(App[None]): + def compose(self) -> ComposeResult: + yield Image() + + async with TestApp().run_test() as pilot: + impl = pilot.app.query_one(_ImageITerm2Impl) + assert impl.render_lines(Region(0, 0, 10, 10)) == [] + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +async def test_render_lines_uses_cache_on_second_call() -> None: + from textual.app import App, ComposeResult + from textual.geometry import Region + + from textual_image.widget.iterm2 import Image, _ImageITerm2Impl + + class TestApp(App[None]): + CSS = "Image { width: 10; height: 10; }" + + def compose(self) -> ComposeResult: + yield Image(TEST_IMAGE) + + async with TestApp().run_test() as pilot: + await pilot.pause() + impl = pilot.app.query_one(_ImageITerm2Impl) + + crop = Region(0, 0, impl.content_size.width, impl.content_size.height) + impl.render_lines(crop) + cached = impl._cached_iterm2_data + + impl.render_lines(crop) + # ponytail: same object means cache was reused, not recomputed + assert impl._cached_iterm2_data is cached + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +async def test_render_lines_clears_widget_area_before_iterm2() -> None: + from textual.app import App, ComposeResult + from textual.geometry import Region + + from textual_image.widget.iterm2 import Image, _ImageITerm2Impl + + class TestApp(App[None]): + CSS = """ + Image { width: 4; height: 3; background: red; } + """ + + def compose(self) -> ComposeResult: + yield Image(PILImage.new("RGBA", (2, 2), (0, 0, 0, 0))) + + async with TestApp().run_test() as pilot: + await pilot.pause() + impl = pilot.app.query_one(_ImageITerm2Impl) + + with patch.object(_ImageITerm2Impl, "_get_iterm2_segments", return_value=[Segment("ITERM2")]): + lines = impl.render_lines(Region(0, 0, 4, 3)) + + assert len(lines) == 3 + for strip in lines: + clear_segment = strip._segments[0] + assert clear_segment.text == " " * 4 + + assert any(seg.text == "ITERM2" for seg in lines[-1]._segments) diff --git a/textual_image/demo/renderable.py b/textual_image/demo/renderable.py index 1adeafb..f4a4d85 100755 --- a/textual_image/demo/renderable.py +++ b/textual_image/demo/renderable.py @@ -15,6 +15,9 @@ from textual_image.renderable import ( Image as AutoRenderable, ) +from textual_image.renderable import ( + ITerm2Image as ITerm2Renderable, +) from textual_image.renderable import ( SixelImage as SixelRenderable, ) @@ -31,6 +34,7 @@ "auto": AutoRenderable, "tgp": TGPRenderable, "sixel": SixelRenderable, + "iterm2": ITerm2Renderable, "halfcell": HalfcellRenderable, "unicode": UnicodeRenderable, } diff --git a/textual_image/demo/widget.py b/textual_image/demo/widget.py index 9e63c59..8ae1206 100755 --- a/textual_image/demo/widget.py +++ b/textual_image/demo/widget.py @@ -15,7 +15,7 @@ from textual.widgets import Button, Footer, Header, Input, Label, OptionList, Select, TabbedContent, TabPane from textual.widgets.option_list import Option -from textual_image.widget import HalfcellImage, SixelImage, TGPImage, UnicodeImage +from textual_image.widget import HalfcellImage, ITerm2Image, SixelImage, TGPImage, UnicodeImage from textual_image.widget import Image as AutoImage TEST_IMAGE = Path(__file__).parent / ".." / "gracehopper.jpg" @@ -25,6 +25,7 @@ "auto": AutoImage, "tgp": TGPImage, "sixel": SixelImage, + "iterm2": ITerm2Image, "halfcell": HalfcellImage, "unicode": UnicodeImage, } diff --git a/textual_image/renderable/__init__.py b/textual_image/renderable/__init__.py index 71b3e87..ced3c63 100644 --- a/textual_image/renderable/__init__.py +++ b/textual_image/renderable/__init__.py @@ -4,8 +4,9 @@ import sys from typing import Type -from textual_image.renderable import sixel, tgp +from textual_image.renderable import iterm2, sixel, tgp from textual_image.renderable.halfcell import Image as HalfcellImage +from textual_image.renderable.iterm2 import Image as ITerm2Image from textual_image.renderable.sixel import Image as SixelImage from textual_image.renderable.tgp import Image as TGPImage from textual_image.renderable.unicode import Image as UnicodeImage @@ -14,13 +15,17 @@ is_tty = sys.__stdout__ and sys.__stdout__.isatty() -Image: Type[TGPImage | SixelImage | HalfcellImage | UnicodeImage] +Image: Type[TGPImage | ITerm2Image | SixelImage | HalfcellImage | UnicodeImage] # TGP should be on top, as it performs way better than Sixel. # However, the only terminal with TGP unicode diacritic support I know of is Kitty. # Konsole and wezterm report TGP support, but don't work with our placeholder implementation, but do with Sixel. # As Kitty does *not* support Sixel, this order should be best in terms of compatibility. -if is_tty and sixel.query_terminal_support(): +# but wezterm supports iterm2, so we check that before sixel +if is_tty and iterm2.query_terminal_support(): + logger.debug("iTerm2 support detected") + Image = ITerm2Image +elif is_tty and sixel.query_terminal_support(): logger.debug("Sixel support detected") Image = SixelImage elif is_tty and tgp.query_terminal_support(): @@ -33,4 +38,4 @@ logger.debug("Not connected to a terminal, falling back to unicode") Image = UnicodeImage -__all__ = ["Image", "TGPImage", "SixelImage", "HalfcellImage", "UnicodeImage"] +__all__ = ["Image", "TGPImage", "SixelImage", "ITerm2Image", "HalfcellImage", "UnicodeImage"] diff --git a/textual_image/renderable/iterm2.py b/textual_image/renderable/iterm2.py new file mode 100644 index 0000000..d49acad --- /dev/null +++ b/textual_image/renderable/iterm2.py @@ -0,0 +1,137 @@ +"""Provides a Rich Renderable to render images via the iTerm2 Inline Images Protocol (https://iterm2.com/documentation-images.html).""" + +import logging +import os +import sys +from typing import IO + +from PIL import Image as PILImage +from rich.console import Console, ConsoleOptions, RenderResult +from rich.control import Control +from rich.measure import Measurement +from rich.segment import ControlType, Segment + +from textual_image._geometry import ImageSize +from textual_image._pixeldata import PixelData +from textual_image._terminal import get_cell_size +from textual_image._utils import StrOrBytesPath + +logger = logging.getLogger(__name__) + +# Random no-op control code to prevent Rich from messing with our data +_NULL_CONTROL = [(ControlType.CURSOR_FORWARD, 0)] + + +def _build_iterm2_sequence(image_data_b64: str, pixel_width: int, pixel_height: int) -> str: + """Build an iTerm2 inline image escape sequence. + + Args: + image_data_b64: Base64-encoded image data (PNG). + pixel_width: Width to render in pixels. + pixel_height: Height to render in pixels. + + Returns: + The complete escape sequence string. + """ + return ( + f"\x1b]1337;File=" + f"inline=1;" + f"width={pixel_width}px;" + f"height={pixel_height}px;" + f"preserveAspectRatio=0" + f":{image_data_b64}\x07" + ) + + +class Image: + """Rich Renderable to render images via the iTerm2 Inline Images Protocol ().""" + + def __init__( + self, + image: StrOrBytesPath | IO[bytes] | PILImage.Image, + width: int | str | None = None, + height: int | str | None = None, + ) -> None: + """Initialized 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. + width: Width specification to render the image. + See `textual_image.geometry.ImageSize` for details about possible values. + height: height specification to render the image. + See `textual_image.geometry.ImageSize` for details about possible values. + """ + self._image_data = PixelData(image) + self._render_size = ImageSize(self._image_data.width, self._image_data.height, width, height) + + def cleanup(self) -> None: + """No-op.""" + pass + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + """Called by Rich to render the `Image`. + + Args: + console: The `Console` instance to render to. + options: Options for rendering, i.e. available size information. + + Returns: + `Segment`s to display. + """ + terminal_sizes = get_cell_size() + + cell_width, cell_height = self._render_size.get_cell_size(options.max_width, options.max_height, terminal_sizes) + pixel_width, pixel_height = self._render_size.get_pixel_size( + options.max_width, options.max_height, terminal_sizes + ) + + # Add a text placeholder for the image that'll be overwritten with the actual image. + # This way rich realizes how much space the renderable uses. + for _ in range(cell_height): + yield Segment(" " * cell_width + "\n") + + # Save cursor position to restore after drawing the image + yield Segment("\x1b7", control=_NULL_CONTROL) + yield Control.move(0, -cell_height) + + image_data_b64 = self._image_data.scaled(pixel_width, pixel_height).to_base64() + sequence = _build_iterm2_sequence(image_data_b64, pixel_width, pixel_height) + + # We add a random no-op control code to prevent Rich from messing with our data + yield Segment(sequence, control=_NULL_CONTROL) + yield Segment("\x1b8", control=_NULL_CONTROL) + + def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: + """Called by Rich to get the render width without actually rendering the object. + + Args: + console: The `Console` instance to render to. + options: Options for rendering, i.e. available size information. + + Returns: + A `Measurement` containing minimum and maximum widths required to render the object + """ + terminal_sizes = get_cell_size() + width, _ = self._render_size.get_cell_size(options.max_width, options.max_height, terminal_sizes) + return Measurement(width, width) + + +def query_terminal_support() -> bool: + """Queries the terminal for iTerm2 Inline Images Protocol support. + + Returns: + True if the iTerm2 Inline Images Protocol is supported, False if not + """ + if not sys.__stdout__: + return False + + # Check TERM_PROGRAM environment variable for quick detection + if os.environ.get("TERM_PROGRAM") in ("iTerm2", "WezTerm"): + return True + + # need some help with feature reporting protocol + # iterm2's documentation says https://iterm2.com/feature-reporting but it + # doesnt work on wezterm, and i dont own a mac, so, help i guess? + + return False diff --git a/textual_image/widget/__init__.py b/textual_image/widget/__init__.py index 8d515c5..f3bbc27 100644 --- a/textual_image/widget/__init__.py +++ b/textual_image/widget/__init__.py @@ -5,11 +5,13 @@ from textual_image._terminal import get_cell_size from textual_image.renderable import Image as AutoRenderable from textual_image.renderable.halfcell import Image as HalfcellRenderable +from textual_image.renderable.iterm2 import Image as ITerm2Renderable from textual_image.renderable.sixel import Image as SixelRenderable from textual_image.renderable.sixel import SixelOptions from textual_image.renderable.tgp import Image as TGPRenderable from textual_image.renderable.unicode import Image as UnicodeRenderable from textual_image.widget._base import Image as BaseImage +from textual_image.widget.iterm2 import Image as ITerm2Image from textual_image.widget.sixel import Image as SixelImage # Run `get_cell_size()` once to fill the cache, @@ -23,10 +25,12 @@ class AutoImage(BaseImage, Renderable=AutoRenderable): pass -# This is bit annoying, but as all renderables but the Sixel one can just be thrown in the base class, while -# we need a dedicated one for Sixel, we have to do this `if`. -if AutoRenderable is SixelRenderable: - Image: Type[AutoImage | SixelImage] = SixelImage +# This is bit annoying, but as all renderables but the Sixel and iTerm2 ones can just be thrown in the base class, +# while we need dedicated ones for Sixel and iTerm2, we have to do this `if`. +if AutoRenderable is ITerm2Renderable: + Image = ITerm2Image +elif AutoRenderable is SixelRenderable: + Image: Type[AutoImage | SixelImage | ITerm2Image] = SixelImage else: Image = AutoImage @@ -54,6 +58,7 @@ class UnicodeImage(BaseImage, Renderable=UnicodeRenderable): "TGPImage", "SixelImage", "SixelOptions", + "ITerm2Image", "HalfcellImage", "UnicodeImage", ] diff --git a/textual_image/widget/iterm2.py b/textual_image/widget/iterm2.py new file mode 100644 index 0000000..da2a140 --- /dev/null +++ b/textual_image/widget/iterm2.py @@ -0,0 +1,199 @@ +"""Provides a Textual `Widget` to render images via the iTerm2 Inline Images Protocol in the terminal.""" + +import logging +from typing import IO, Iterable, NamedTuple + +from PIL import Image as PILImage +from rich.console import Console, ConsoleOptions, RenderResult +from rich.control import Control +from rich.measure import Measurement +from rich.segment import ControlType, Segment +from rich.style import Style +from textual.app import ComposeResult +from textual.dom import NoScreen +from textual.geometry import Region, Size +from textual.strip import Strip +from textual.widget import Widget +from typing_extensions import override + +from textual_image._geometry import ImageSize +from textual_image._pixeldata import PixelData +from textual_image._terminal import CellSize, get_cell_size +from textual_image._utils import StrOrBytesPath +from textual_image.renderable.iterm2 import _build_iterm2_sequence +from textual_image.widget._base import Image as BaseImage + +logger = logging.getLogger(__name__) + + +_NULL_STYLE = Style() + + +class _CachedITerm2Data(NamedTuple): + image: StrOrBytesPath | IO[bytes] | PILImage.Image + content_crop: Region + content_size: Size + terminal_sizes: CellSize + iterm2_data: str + + def is_hit( + self, + image: StrOrBytesPath | IO[bytes] | PILImage.Image, + content_crop: Region, + content_size: Size, + terminal_sizes: CellSize, + ) -> bool: + """Check if cached data matches the current parameters.""" + return ( + image == self.image + and content_crop == self.content_crop + and content_size == self.content_size + and terminal_sizes == self.terminal_sizes + ) + + +class _NoopRenderable: + """Image renderable rendering nothing. + + Used by the iTerm2 image as placeholder. + Rendering the iTerm2 renderable doesn't work with Textual as it relies on printable segments. + Instead, iTerm2 data is injected into the rendering process. To keep our base class happy, we use this class + as renderable passed to it. + """ + + def __init__( + self, + image: StrOrBytesPath | IO[bytes] | PILImage.Image, + width: int | str | None = None, + height: int | str | None = None, + ) -> None: + pass + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + yield Segment("") + + def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: + return Measurement(0, 0) + + def cleanup(self) -> None: + pass + + +class Image(BaseImage, Renderable=_NoopRenderable): + """Textual `Widget` to render images via the iTerm2 Inline Images Protocol in the terminal.""" + + @override + @BaseImage.image.setter # type: ignore + def image(self, value: StrOrBytesPath | IO[bytes] | PILImage.Image | None) -> None: + """Set the image to render.""" + super(__class__, type(self)).image.fset(self, value) # type: ignore + self.refresh(recompose=True) + + def compose(self) -> ComposeResult: + """Called by Textual to create child widgets.""" + yield _ImageITerm2Impl(self.image) + + +class _ImageITerm2Impl(Widget, can_focus=False, inherit_css=False): + """Widget implementation injecting iTerm2 image data into the rendering process. + + This class is meant to be used only by `textual_image.widget.iterm2.Image`. + It creates and renders iTerm2 inline image data. + + It is done in this child widget to simplify the process -- this class assumes it never has to render any borders or + spacings, but only the parent will if required by the user. + We assume `self.region == self.content_region` in this class, which lets us use the `crop` parameter + in `render_lines()` directly on our image data without having to deal with gutters, as well as moving + the cursor after rendering the iTerm2 data to an easily determinable position. + """ + + DEFAULT_CSS = """ + _ImageData { + width: 100%; + height: 100%; + } + """ + + @override + def __init__( + self, + image: StrOrBytesPath | IO[bytes] | PILImage.Image | None = None, + ) -> None: + super().__init__() + self.image = image + self._cached_iterm2_data: _CachedITerm2Data | None = None + + @override + def render_lines(self, crop: Region) -> list[Strip]: + # We don't render anything if the screen isn't active. Textual may try to tint the widget which leads to weird + # effects. + try: + if not self.image or not self.screen.is_active: + return [] + except NoScreen: # if no screen, return empty list + return [] + + # Inject the iTerm2 data. We can only do it here because we don't know the crop region before. + terminal_sizes = get_cell_size() + + if self._cached_iterm2_data and self._cached_iterm2_data.is_hit( + self.image, crop, self.content_size, terminal_sizes + ): + logger.debug(f"using iTerm2 data from cache for crop region {crop}") + iterm2_data = self._cached_iterm2_data.iterm2_data + else: + logger.debug(f"encoding iTerm2 data for crop region {crop}") + + image_data = PixelData(self.image) + image_data = self._scale_image(image_data, terminal_sizes) + image_data = self._crop_image(image_data, crop, terminal_sizes) + + iterm2_data = _build_iterm2_sequence( + image_data.to_base64(), + crop.width * terminal_sizes.width, + crop.height * terminal_sizes.height, + ) + self._cached_iterm2_data = _CachedITerm2Data( + self.image, crop, self.content_size, terminal_sizes, iterm2_data + ) + + iterm2_segments = self._get_iterm2_segments(iterm2_data) + clear_style = self._get_clear_style() + clear_segment = Segment(" " * crop.width, style=clear_style) + lines = [Strip([clear_segment], cell_length=crop.width) for _ in range(crop.height - 1)] + lines.append(Strip([clear_segment, *iterm2_segments], cell_length=crop.width)) + return lines + + def _scale_image(self, image_data: PixelData, terminal_sizes: CellSize) -> PixelData: + assert isinstance(self.parent, Image) + + styled_width, styled_height = self.parent._get_styled_size() + image_size = ImageSize(image_data.width, image_data.height, width=styled_width, height=styled_height) + pixel_width, pixel_height = image_size.get_pixel_size( + self.content_size.width, self.content_size.height, terminal_sizes + ) + + return image_data.scaled(pixel_width, pixel_height) + + def _crop_image(self, image: PixelData, crop: Region, terminal_sizes: CellSize) -> PixelData: + crop_pixels_left = crop.x * terminal_sizes.width + crop_pixels_top = crop.y * terminal_sizes.height + crop_pixels_right = crop.right * terminal_sizes.width + crop_pixels_bottom = crop.bottom * terminal_sizes.height + + return image.cropped(crop_pixels_left, crop_pixels_top, crop_pixels_right, crop_pixels_bottom) + + def _get_iterm2_segments(self, iterm2_data: str) -> Iterable[Segment]: + visible_region = self.screen.find_widget(self).visible_region + return [ + Segment( + Control.move_to(visible_region.x, visible_region.y).segment.text, + style=_NULL_STYLE, + ), + Segment(iterm2_data, style=_NULL_STYLE, control=((ControlType.CURSOR_FORWARD, 0),)), + Segment(Control.move_to(visible_region.right, visible_region.bottom - 2).segment.text, style=_NULL_STYLE), + ] + + def _get_clear_style(self) -> Style: + _, color = self.background_colors + return Style(bgcolor=color.rich_color)