From 8cd1247a46c77aef7a267a4d1a71611a78175890 Mon Sep 17 00:00:00 2001 From: NSPC911 <87571998+NSPC911@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:40:35 +0800 Subject: [PATCH] feat: add kitty image support --- tests/renderable/test_init.py | 27 +++- tests/renderable/test_kitty.py | 81 ++++++++++ tests/widget/test_kitty.py | 66 ++++++++ textual_image/demo/renderable.py | 4 + textual_image/demo/widget.py | 11 +- textual_image/renderable/__init__.py | 20 ++- textual_image/renderable/kitty.py | 155 +++++++++++++++++++ textual_image/widget/__init__.py | 17 ++ textual_image/widget/kitty.py | 222 +++++++++++++++++++++++++++ 9 files changed, 589 insertions(+), 14 deletions(-) create mode 100644 tests/renderable/test_kitty.py create mode 100644 tests/widget/test_kitty.py create mode 100644 textual_image/renderable/kitty.py create mode 100644 textual_image/widget/kitty.py diff --git a/tests/renderable/test_init.py b/tests/renderable/test_init.py index 1d0f8c8..afc88b3 100644 --- a/tests/renderable/test_init.py +++ b/tests/renderable/test_init.py @@ -4,7 +4,7 @@ def test_determining_best_renderable() -> None: import textual_image.renderable - from textual_image.renderable import halfcell, sixel, tgp, unicode + from textual_image.renderable import halfcell, iterm2, kitty, sixel, tgp, unicode with patch("sys.__stdout__.isatty", return_value=True): with patch("textual_image.renderable.tgp.query_terminal_support", return_value=True): @@ -12,14 +12,29 @@ def test_determining_best_renderable() -> None: assert module.Image is tgp.Image with patch("textual_image.renderable.tgp.query_terminal_support", return_value=False): - with patch("textual_image.renderable.sixel.query_terminal_support", return_value=True): + with patch("textual_image.renderable.kitty.query_terminal_support", return_value=True): module = reload(textual_image.renderable) - assert module.Image is sixel.Image + assert module.Image is kitty.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.kitty.query_terminal_support", return_value=False): + with patch("textual_image.renderable.iterm2.query_terminal_support", return_value=True): + module = reload(textual_image.renderable) + assert module.Image is iterm2.Image + + with patch("textual_image.renderable.tgp.query_terminal_support", return_value=False): + with patch("textual_image.renderable.kitty.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.kitty.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=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_kitty.py b/tests/renderable/test_kitty.py new file mode 100644 index 0000000..16eadc2 --- /dev/null +++ b/tests/renderable/test_kitty.py @@ -0,0 +1,81 @@ +from itertools import repeat +import re +from unittest.mock import patch + +from rich.console import Console +from rich.measure import Measurement + +from tests.data import CONSOLE_OPTIONS, TEST_IMAGE +from tests.utils import load_non_seekable_bytes_io, render + + +def test_render() -> None: + from textual_image.renderable.kitty import Image + + renderable = Image(TEST_IMAGE, width=4) + + with patch.object(Image, "_image_id_counter", repeat(1337)): + output = render(renderable) + + assert "\x1b7" in output + assert re.search(r"\x1b_Ga=T,i=1337,c=4,r=\d+,C=1,f=100,m=[01],q=2;", output) + assert output.endswith("\x1b8") + + +def test_render_non_seekable() -> None: + from textual_image.renderable.kitty import Image + + test_image = load_non_seekable_bytes_io(TEST_IMAGE) + renderable = Image(test_image) + assert test_image.read() == b"" + + first = render(renderable) + second = render(renderable) + + assert "\x1b7" in first + assert "\x1b7" in second + + test_image.close() + assert "\x1b7" in render(renderable) + + +def test_measure() -> None: + from textual_image.renderable.kitty 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.kitty import Image + + renderable = Image(TEST_IMAGE, width=4) + + with patch("textual_image.renderable.kitty._send_tgp_message") as send_tgp_message: + renderable.cleanup() + + assert not send_tgp_message.called + + with patch.object(Image, "_image_id_counter", repeat(1337)): + render(renderable) + + with patch("textual_image.renderable.kitty._send_tgp_message") as send_tgp_message: + renderable.cleanup() + + assert send_tgp_message.call_args.kwargs == {"a": "d", "d": "I", "i": 1337, "q": 2} + + +def test_build_tgp_message() -> None: + from textual_image.renderable.kitty import _build_tgp_message + + assert _build_tgp_message(a="T", i=42, payload="AAAA") == "\x1b_Ga=T,i=42;AAAA\x1b\\" + + +def test_query_terminal_support() -> None: + from textual_image.renderable.kitty import query_terminal_support + + with patch("textual_image.renderable.kitty.query_tgp_terminal_support", return_value=True): + assert query_terminal_support() + + with patch("textual_image.renderable.kitty.query_tgp_terminal_support", return_value=False): + assert not query_terminal_support() diff --git a/tests/widget/test_kitty.py b/tests/widget/test_kitty.py new file mode 100644 index 0000000..9bae67f --- /dev/null +++ b/tests/widget/test_kitty.py @@ -0,0 +1,66 @@ +from unittest import skipUnless +from unittest.mock import patch + +from PIL import Image as PILImage +from PIL import ImageOps +from rich.console import Console +from rich.measure import Measurement + +from tests.data import CONSOLE_OPTIONS, TEST_IMAGE, TEXTUAL_ENABLED + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +async def test_app() -> None: + from textual.app import App, ComposeResult + + from textual_image.widget.kitty 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() + yield Image(classes="auto") + + app = TestApp() + + with patch("textual_image.widget.kitty._send_tgp_message"): + 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() + await pilot.app.run_action("app.command_palette") + await pilot.pause() + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +def test_measure_noop_renderable() -> None: + from textual_image.widget.kitty import _NoopRenderable + + assert _NoopRenderable(TEST_IMAGE).__rich_measure__(Console(), CONSOLE_OPTIONS) == Measurement(0, 0) + + +@skipUnless(TEXTUAL_ENABLED, "Textual support disabled") +def test_on_unmount_cleanup() -> None: + from textual_image.widget.kitty import _ImageKittyImpl + + with patch("textual_image.widget.kitty._send_tgp_message") as send_tgp_message: + widget = _ImageKittyImpl() + widget.on_unmount() + + assert send_tgp_message.call_args.kwargs["a"] == "d" + assert send_tgp_message.call_args.kwargs["d"] == "I" + assert send_tgp_message.call_args.kwargs["q"] == 2 diff --git a/textual_image/demo/renderable.py b/textual_image/demo/renderable.py index 1adeafb..e8a044d 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 ( + KittyImage as KittyRenderable, +) from textual_image.renderable import ( SixelImage as SixelRenderable, ) @@ -29,6 +32,7 @@ RENDERING_METHODS = { "auto": AutoRenderable, + "kitty": KittyRenderable, "tgp": TGPRenderable, "sixel": SixelRenderable, "halfcell": HalfcellRenderable, diff --git a/textual_image/demo/widget.py b/textual_image/demo/widget.py index 9e63c59..84df44a 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, KittyImage, SixelImage, TGPImage, UnicodeImage from textual_image.widget import Image as AutoImage TEST_IMAGE = Path(__file__).parent / ".." / "gracehopper.jpg" @@ -23,6 +23,7 @@ RENDERING_METHODS = { "auto": AutoImage, + "kitty": KittyImage, "tgp": TGPImage, "sixel": SixelImage, "halfcell": HalfcellImage, @@ -317,14 +318,18 @@ def compose(self) -> ComposeResult: def action_select_rendering_method(self) -> None: """Shows a modal to select the rendering method.""" assert self.image_type - self.push_screen(RenderingMethodSelectionScreen(self.image_type), lambda m: self.set_rendering_method(m)) + self.push_screen( + RenderingMethodSelectionScreen(cast(str, self.image_type)), lambda m: self.set_rendering_method(m) + ) - def set_rendering_method(self, rendering_method: str) -> None: + def set_rendering_method(self, rendering_method: str | None) -> None: """Sets the rendering method. Args: rendering_method: Rendering method to use. """ + if rendering_method is None: + return self.image_type = rendering_method diff --git a/textual_image/renderable/__init__.py b/textual_image/renderable/__init__.py index 71b3e87..8fc1adc 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 kitty, sixel, tgp from textual_image.renderable.halfcell import Image as HalfcellImage +from textual_image.renderable.kitty import Image as KittyImage 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,15 @@ is_tty = sys.__stdout__ and sys.__stdout__.isatty() -Image: Type[TGPImage | SixelImage | HalfcellImage | UnicodeImage] +Image: Type[TGPImage | KittyImage | 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(): +if is_tty and kitty.query_terminal_support(): + logger.debug("kitty graphics protocol support detected") + Image = KittyImage +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 +36,11 @@ logger.debug("Not connected to a terminal, falling back to unicode") Image = UnicodeImage -__all__ = ["Image", "TGPImage", "SixelImage", "HalfcellImage", "UnicodeImage"] +__all__ = [ + "Image", + "KittyImage", + "TGPImage", + "SixelImage", + "HalfcellImage", + "UnicodeImage", +] diff --git a/textual_image/renderable/kitty.py b/textual_image/renderable/kitty.py new file mode 100644 index 0000000..0c9d0cb --- /dev/null +++ b/textual_image/renderable/kitty.py @@ -0,0 +1,155 @@ +"""Provides a Rich Renderable implementing basic kitty graphics protocol placement.""" + +from itertools import count +from random import randint +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 TerminalError, get_cell_size +from textual_image._utils import StrOrBytesPath +from textual_image.renderable.tgp import _send_tgp_message, query_terminal_support as query_tgp_terminal_support + +# Random no-op control code to prevent Rich from messing with our data +_NULL_CONTROL = [(ControlType.CURSOR_FORWARD, 0)] + +_TGP_MESSAGE_START = "\x1b_G" +_TGP_MESSAGE_END = "\x1b\\" + + +def _build_tgp_message(*, payload: str | None = None, **kwargs: int | str | None) -> str: + return "".join( + [ + _TGP_MESSAGE_START, + ",".join(f"{k}={v}" for k, v in kwargs.items() if v is not None), + f";{payload}" if payload else "", + _TGP_MESSAGE_END, + ] + ) + + +class Image: + """Rich Renderable implementing basic kitty graphics protocol image placement.""" + + _image_id_counter = count(randint(1, 2**32)) + + 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) + self._terminal_image_id: int | None = None + + def cleanup(self) -> None: + """Free image data from terminal. + + Clears image data from terminal. If data wasn't sent yet or is already freed, this method is a no-op. + """ + if self._terminal_image_id is None: + return + + try: + _send_tgp_message(a="d", d="I", i=self._terminal_image_id, q=2) + except TerminalError: + pass + finally: + self._terminal_image_id = None + + 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 + ) + + if self._terminal_image_id is None: + self._terminal_image_id = next(self._image_id_counter) + + # 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) + + scaled_image = self._image_data.scaled(pixel_width, pixel_height) + control_data = self._image_to_control_data(scaled_image.to_base64(), cell_width, cell_height) + + # We add a random no-op control code to prevent Rich from messing with our data + yield Segment(control_data, 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 _image_to_control_data(self, image_data_b64: str, cell_width: int, cell_height: int) -> str: + assert self._terminal_image_id is not None + + chunks: list[str] = [] + while image_data_b64: + chunk, image_data_b64 = image_data_b64[:4096], image_data_b64[4096:] + chunks.append( + _build_tgp_message( + a="T", + i=self._terminal_image_id, + c=cell_width, + r=cell_height, + C=1, + f=100, + m=1 if image_data_b64 else 0, + q=2, + payload=chunk, + ) + ) + + return "".join(chunks) + + +def query_terminal_support() -> bool: + """Queries terminal for kitty graphics protocol support.""" + return query_tgp_terminal_support() + + +__all__ = ["Image", "query_terminal_support"] diff --git a/textual_image/widget/__init__.py b/textual_image/widget/__init__.py index 8d515c5..833626f 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.kitty import Image as KittyRenderable 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.kitty import Image as KittyImage from textual_image.widget.sixel import Image as SixelImage # Run `get_cell_size()` once to fill the cache, @@ -49,9 +51,24 @@ class UnicodeImage(BaseImage, Renderable=UnicodeRenderable): pass +# When auto-detection picks a protocol that requires Textual-specific rendering (crop/viewport context), +# use the widget version instead of the renderable directly. +if AutoRenderable is TGPRenderable: + Image: Type[AutoImage | TGPImage | KittyImage | ITerm2Image | SixelImage] = TGPImage +elif AutoRenderable is KittyRenderable: + Image = KittyImage +elif AutoRenderable is ITerm2Renderable: + Image = ITerm2Image +elif AutoRenderable is SixelRenderable: + Image = SixelImage +else: + Image = AutoImage + + __all__ = [ "Image", "TGPImage", + "KittyImage", "SixelImage", "SixelOptions", "HalfcellImage", diff --git a/textual_image/widget/kitty.py b/textual_image/widget/kitty.py new file mode 100644 index 0000000..3bf6886 --- /dev/null +++ b/textual_image/widget/kitty.py @@ -0,0 +1,222 @@ +"""Provides a Textual `Widget` implementing basic kitty graphics protocol placement.""" + +import logging +from itertools import count +from random import randint +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, TerminalError, get_cell_size +from textual_image._utils import StrOrBytesPath +from textual_image.renderable.tgp import _send_tgp_message +from textual_image.widget._base import Image as BaseImage + +logger = logging.getLogger(__name__) + + +_NULL_STYLE = Style() + +_TGP_MESSAGE_START = "\x1b_G" +_TGP_MESSAGE_END = "\x1b\\" + + +def _build_tgp_message(*, payload: str | None = None, **kwargs: int | str | None) -> str: + return "".join( + [ + _TGP_MESSAGE_START, + ",".join(f"{k}={v}" for k, v in kwargs.items() if v is not None), + f";{payload}" if payload else "", + _TGP_MESSAGE_END, + ] + ) + + +class _CachedPlacement(NamedTuple): + image: StrOrBytesPath | IO[bytes] | PILImage.Image + content_crop: Region + content_size: Size + terminal_sizes: CellSize + control_data: str + + def is_hit( + self, + image: StrOrBytesPath | IO[bytes] | PILImage.Image, + content_crop: Region, + content_size: Size, + terminal_sizes: CellSize, + ) -> bool: + 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 kitty widget as placeholder. + Rendering from rich renderables does not work with Textual for this implementation, + as we need crop and viewport information available only in `render_lines()`. + """ + + 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` using basic kitty graphics protocol image placement.""" + + def compose(self) -> ComposeResult: + """Called by Textual to create child widgets.""" + yield _ImageKittyImpl() + + +class _ImageKittyImpl(Widget, can_focus=False, inherit_css=False): + """Widget implementation injecting kitty graphics protocol control data. + + This class is meant to be used only by `textual_image.widget.kitty.Image`. + """ + + DEFAULT_CSS = """ + _ImageKittyImpl { + width: 100%; + height: 100%; + } + """ + + _image_id_counter = count(randint(1, 2**32)) + + @override + def __init__(self) -> None: + super().__init__() + self._cached_placement: _CachedPlacement | None = None + self._terminal_image_id = next(self._image_id_counter) + + def on_unmount(self) -> None: + try: + _send_tgp_message(a="d", d="I", i=self._terminal_image_id, q=2) + except TerminalError: + pass + + @override + def render_lines(self, crop: Region) -> list[Strip]: + assert isinstance(self.parent, Image) + + try: + if not self.parent.image or not self.screen.is_active: + return [] + except NoScreen: + return [] + + terminal_sizes = get_cell_size() + + if self._cached_placement and self._cached_placement.is_hit( + self.parent.image, crop, self.content_size, terminal_sizes + ): + logger.debug(f"using kitty placement data from cache for crop region {crop}") + control_data = self._cached_placement.control_data + else: + logger.debug(f"encoding kitty placement data for crop region {crop}") + image_data = PixelData(self.parent.image) + image_data = self._scale_image(image_data, terminal_sizes) + image_data = self._crop_image(image_data, crop, terminal_sizes) + control_data = self._image_to_control_data(image_data, crop) + self._cached_placement = _CachedPlacement( + self.parent.image, + crop, + self.content_size, + terminal_sizes, + control_data, + ) + + kitty_segments = self._get_kitty_segments(control_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, *kitty_segments], cell_length=crop.width)) + return lines + + def _image_to_control_data(self, image_data: PixelData, crop: Region) -> str: + base64_data = image_data.to_base64() + chunks: list[str] = [] + while base64_data: + chunk, base64_data = base64_data[:4096], base64_data[4096:] + chunks.append( + _build_tgp_message( + a="T", + i=self._terminal_image_id, + c=crop.width, + r=crop.height, + C=1, + f=100, + m=1 if base64_data else 0, + q=2, + payload=chunk, + ) + ) + + return "".join(chunks) + + 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_kitty_segments(self, control_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(control_data, style=_NULL_STYLE, control=((ControlType.CURSOR_FORWARD, 0),)), + Segment(Control.move_to(visible_region.right, visible_region.bottom).segment.text, style=_NULL_STYLE), + ] + + def _get_clear_style(self) -> Style: + _, color = self.background_colors + return Style(bgcolor=color.rich_color)