From 40bfe532b8751d8286fb754c1b4f647be04ba980 Mon Sep 17 00:00:00 2001 From: NSPBot911 <176916861+NSPBot911@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:56:45 +0800 Subject: [PATCH 1/2] feat: add iTerm2 inline image support --- .../docs/dev/features/image-previews.mdx | 6 +- .../content/docs/features/image-previews.mdx | 2 +- src/rovr/classes/config.pyi | 3 + src/rovr/components/iterm2_image.py | 205 ++++++++++++++++++ src/rovr/config/schema.json | 2 +- src/rovr/core/preview_container.py | 13 +- src/rovr/first_launch.py | 7 +- tests/test_iterm2_image.py | 36 +++ 8 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 src/rovr/components/iterm2_image.py create mode 100644 tests/test_iterm2_image.py diff --git a/docs/src/content/docs/dev/features/image-previews.mdx b/docs/src/content/docs/dev/features/image-previews.mdx index 56b81e58..2439454f 100644 --- a/docs/src/content/docs/dev/features/image-previews.mdx +++ b/docs/src/content/docs/dev/features/image-previews.mdx @@ -29,7 +29,7 @@ here is a summary of terminals and their support for the image protocols used by | Windows Terminal | ❌ | ✅ | | Xterm | ❌ | ✅ | -> the `iterm2 inline images` protocol is not supported. see [lnqs/textual-image #68](https://github.com/lnqs/textual-image/issues/68) for more details. +> rovr includes its own iTerm2 Inline Image Protocol widget. With `"Auto"`, it is selected automatically in a TTY when `TERM_PROGRAM` is `iTerm2` or `WezTerm`. ### configuring the image protocol @@ -38,12 +38,12 @@ if you are using a terminal that doesn't support the default image protocol, `te if images do not appear correctly, you can manually override the image protocol in your `config.toml` file. -available `image_protocol` values are: `"Auto"`, `"TGP"`, `"Sixel"`, `"Halfcell"`, and `"Unicode"`. +available `protocol` values are: `"Auto"`, `"TGP"`, `"ITerm2"`, `"Sixel"`, `"Halfcell"`, and `"Unicode"`. ### configuring the image size diff --git a/docs/src/content/docs/features/image-previews.mdx b/docs/src/content/docs/features/image-previews.mdx index 56b81e58..f21eee47 100644 --- a/docs/src/content/docs/features/image-previews.mdx +++ b/docs/src/content/docs/features/image-previews.mdx @@ -38,7 +38,7 @@ if you are using a terminal that doesn't support the default image protocol, `te if images do not appear correctly, you can manually override the image protocol in your `config.toml` file. diff --git a/src/rovr/classes/config.pyi b/src/rovr/classes/config.pyi index 9e416b22..00c73e9b 100644 --- a/src/rovr/classes/config.pyi +++ b/src/rovr/classes/config.pyi @@ -826,6 +826,7 @@ r""" minimum: 1 """ _RovrConfigInterfaceImageViewerProtocol = ( Literal["Auto"] | Literal["TGP"] + | Literal["ITerm2"] | Literal["Sixel"] | Literal["Halfcell"] | Literal["Unicode"] @@ -839,6 +840,8 @@ _ROVRCONFIGINTERFACEIMAGEVIEWERPROTOCOL_AUTO: Literal["Auto"] = "Auto" r"""The values for the 'The image protocol to use when displaying an image' enum""" _ROVRCONFIGINTERFACEIMAGEVIEWERPROTOCOL_TGP: Literal["TGP"] = "TGP" r"""The values for the 'The image protocol to use when displaying an image' enum""" +_ROVRCONFIGINTERFACEIMAGEVIEWERPROTOCOL_ITERM2: Literal["ITerm2"] = "ITerm2" +r"""The values for the 'The image protocol to use when displaying an image' enum""" _ROVRCONFIGINTERFACEIMAGEVIEWERPROTOCOL_SIXEL: Literal["Sixel"] = "Sixel" r"""The values for the 'The image protocol to use when displaying an image' enum""" _ROVRCONFIGINTERFACEIMAGEVIEWERPROTOCOL_HALFCELL: Literal["Halfcell"] = "Halfcell" diff --git a/src/rovr/components/iterm2_image.py b/src/rovr/components/iterm2_image.py new file mode 100644 index 00000000..ce1eb3ef --- /dev/null +++ b/src/rovr/components/iterm2_image.py @@ -0,0 +1,205 @@ +"""Textual widget support for the iTerm2 Inline Image Protocol.""" + +import os +import sys +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 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.widget._base import Image as BaseImage +from typing_extensions import override + +_NULL_STYLE = Style() + + +def is_supported() -> bool: + """Return whether the active terminal supports iTerm2 inline images. + + Returns: + Whether the terminal advertises iTerm2 Inline Image Protocol support. + """ + return bool( + sys.__stdout__ + and sys.__stdout__.isatty() + and os.environ.get("TERM_PROGRAM") in ("iTerm2", "WezTerm") + ) + + +def _build_sequence(image_data_b64: str, pixel_width: int, pixel_height: int) -> str: + return ( + "\x1b]1337;File=" + f"inline=1;width={pixel_width}px;height={pixel_height}px;preserveAspectRatio=0" + f":{image_data_b64}\x07" + ) + + +class _CachedImageData(NamedTuple): + image: StrOrBytesPath | IO[bytes] | PILImage.Image + content_crop: Region + content_size: Size + terminal_sizes: CellSize + 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: + 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 ITerm2Image(BaseImage, Renderable=_NoopRenderable): + """Render an image through the iTerm2 Inline Image Protocol.""" + + @override + @BaseImage.image.setter + def image(self, value: StrOrBytesPath | IO[bytes] | PILImage.Image | None) -> None: + super(__class__, type(self)).image.fset(self, value) + self.refresh(recompose=True) + + def compose(self) -> ComposeResult: + yield _ImageImpl(self.image) + + +class _ImageImpl(Widget, can_focus=False, inherit_css=False): + DEFAULT_CSS = """ + _ImageImpl { + width: 100%; + height: 100%; + } + """ + + @override + def __init__( + self, + image: StrOrBytesPath | IO[bytes] | PILImage.Image | None = None, + ) -> None: + super().__init__() + self.image = image + self._cached_data: _CachedImageData | None = None + + @override + def render_lines(self, crop: Region) -> list[Strip]: + try: + if not self.image or not self.screen.is_active: + return [] + except NoScreen: + return [] + + terminal_sizes = get_cell_size() + if self._cached_data and self._cached_data.is_hit( + self.image, crop, self.content_size, terminal_sizes + ): + data = self._cached_data.data + else: + image_data = PixelData(self.image) + image_data = self._scale_image(image_data, terminal_sizes) + image_data = self._crop_image(image_data, crop, terminal_sizes) + data = _build_sequence( + image_data.to_base64(), + crop.width * terminal_sizes.width, + crop.height * terminal_sizes.height, + ) + self._cached_data = _CachedImageData( + self.image, crop, self.content_size, terminal_sizes, data + ) + + clear_segment = Segment(" " * crop.width, style=self._get_clear_style()) + image_segments = self._get_image_segments(data) + lines = [ + Strip([clear_segment], cell_length=crop.width) + for _ in range(crop.height - 1) + ] + lines.append(Strip([clear_segment, *image_segments], cell_length=crop.width)) + return lines + + def _scale_image( + self, image_data: PixelData, terminal_sizes: CellSize + ) -> PixelData: + assert isinstance(self.parent, ITerm2Image) + 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: + return image.cropped( + crop.x * terminal_sizes.width, + crop.y * terminal_sizes.height, + crop.right * terminal_sizes.width, + crop.bottom * terminal_sizes.height, + ) + + def _get_image_segments(self, 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( + 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) diff --git a/src/rovr/config/schema.json b/src/rovr/config/schema.json index f6cf78fd..bb990189 100644 --- a/src/rovr/config/schema.json +++ b/src/rovr/config/schema.json @@ -293,7 +293,7 @@ "type": "string", "default": "Auto", "description": "The image protocol to use when displaying an image", - "enum": ["Auto", "TGP", "Sixel", "Halfcell", "Unicode"] + "enum": ["Auto", "TGP", "ITerm2", "Sixel", "Halfcell", "Unicode"] }, "max_size": { "type": "array", diff --git a/src/rovr/core/preview_container.py b/src/rovr/core/preview_container.py index 00100de2..0a08d43f 100644 --- a/src/rovr/core/preview_container.py +++ b/src/rovr/core/preview_container.py @@ -30,6 +30,8 @@ ArchiveFileListSelection, FileListSelectionWidget, ) +from rovr.components.iterm2_image import ITerm2Image +from rovr.components.iterm2_image import is_supported as iterm2_supported from rovr.core import FileList from rovr.functions import icons as icon_utils from rovr.functions import path as path_utils @@ -45,11 +47,14 @@ # yes i know this is a hidden module, and yes, i will # continue using it, because i cant use a variable # (the variable being textual_image.widget.Image) +image_protocol = config["interface"]["image_viewer"]["protocol"] +image_widget = ( + ITerm2Image + if image_protocol == "ITerm2" or (not image_protocol and iterm2_supported()) + else textual_image.widget.__dict__[image_protocol + "Image"] +) NewImage: partial[textual_image.widget._base.Image] = partial( - textual_image.widget.__dict__[ - config["interface"]["image_viewer"]["protocol"] + "Image" - ], - classes="image_preview", + image_widget, classes="image_preview" ) diff --git a/src/rovr/first_launch.py b/src/rovr/first_launch.py index b70faac3..1cf11bb0 100644 --- a/src/rovr/first_launch.py +++ b/src/rovr/first_launch.py @@ -37,6 +37,8 @@ Switch, ) +from rovr.components.iterm2_image import ITerm2Image +from rovr.components.iterm2_image import is_supported as iterm2_supported from rovr.functions.themes import register_all_themes, resolve_theme_ansi from rovr.functions.utils import should_cancel from rovr.variables.maps import RovrVars @@ -47,8 +49,9 @@ resource = resources.files("rovr") prot_to_timg: dict[str, Callable] = { - "auto": timg.Image, + "auto": ITerm2Image if iterm2_supported() else timg.Image, "tgp": timg.TGPImage, + "iterm2": ITerm2Image, "sixel": timg.SixelImage, "halfcell": timg.HalfcellImage, "unicode": timg.UnicodeImage, @@ -58,6 +61,7 @@ prot_to_schema: dict[str, str] = { "auto": "Auto", "tgp": "TGP", + "iterm2": "ITerm2", "sixel": "Sixel", "halfcell": "Halfcell", "unicode": "Unicode", @@ -267,6 +271,7 @@ def compose(self) -> ComposeResult: ( ("Auto", "auto"), ("TGP/Kitty (might be broken)", "tgp"), + ("iTerm2", "iterm2"), ("Sixel", "sixel"), ("HalfCell", "halfcell"), ("Unicode (not recommended)", "unicode"), diff --git a/tests/test_iterm2_image.py b/tests/test_iterm2_image.py new file mode 100644 index 00000000..01eb05f7 --- /dev/null +++ b/tests/test_iterm2_image.py @@ -0,0 +1,36 @@ +import sys + +import pytest + +from rovr.components.iterm2_image import _build_sequence, is_supported + + +class _Stdout: + def __init__(self, is_tty: bool) -> None: + self._is_tty = is_tty + + def isatty(self) -> bool: + return self._is_tty + + +def test_build_sequence() -> None: + assert _build_sequence("image", 100, 200) == ( + "\x1b]1337;File=inline=1;width=100px;height=200px;preserveAspectRatio=0:image\x07" + ) + + +@pytest.mark.parametrize("terminal", ["iTerm2", "WezTerm"]) +def test_is_supported_for_iterm2_terminals( + monkeypatch: pytest.MonkeyPatch, terminal: str +) -> None: + monkeypatch.setattr(sys, "__stdout__", _Stdout(True)) + monkeypatch.setenv("TERM_PROGRAM", terminal) + + assert is_supported() + + +def test_is_supported_requires_a_tty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "__stdout__", _Stdout(False)) + monkeypatch.setenv("TERM_PROGRAM", "WezTerm") + + assert not is_supported() From d27c79f5196c7d0818d6f149bf04d16bb7761e0b Mon Sep 17 00:00:00 2001 From: NSPC911 <87571998+NSPC911@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:33:35 +0800 Subject: [PATCH 2/2] fix: doc changes + other stuff --- .../docs/dev/features/image-previews.mdx | 74 +++++++++++-------- .../content/docs/features/image-previews.mdx | 44 +++++++---- src/rovr/components/iterm2_image.py | 8 +- src/rovr/config/schema.json | 6 +- 4 files changed, 81 insertions(+), 51 deletions(-) diff --git a/docs/src/content/docs/dev/features/image-previews.mdx b/docs/src/content/docs/dev/features/image-previews.mdx index 2439454f..f997b857 100644 --- a/docs/src/content/docs/dev/features/image-previews.mdx +++ b/docs/src/content/docs/dev/features/image-previews.mdx @@ -13,21 +13,21 @@ however, image support depends on the capabilities of your terminal emulator. di here is a summary of terminals and their support for the image protocols used by `rovr`: -| Terminal | TGP support | Sixel support | -| ------------------ | :---------: | :-----------: | -| 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 | +| ------------------ | :---------: | :-----------: | :------------: | +| Black Box | ❌ | ✅ | ❌ | +| Foot | ❌ | ✅ | ❌ | +| Gnome Terminal | ❌ | ❌ | ❌ | +| Iterm2 | ❌ | ✅ | ✅ | +| Kitty | ✅ | ❌ | ❌ | +| Konsole | ✅ | ✅ | ❌ | +| Tmux | ❌ | ✅ | ❌ | +| Visual Studio Code | ❌ | ✅ | ❌ | +| Warp | ❌ | ❌ | ❌ | +| Wezterm | ❌ | ✅ | ✅ | +| Windows Console | ❌ | ❌ | ❌ | +| Windows Terminal | ❌ | ✅ | ❌ | +| Xterm | ❌ | ✅ | ❌ | > rovr includes its own iTerm2 Inline Image Protocol widget. With `"Auto"`, it is selected automatically in a TTY when `TERM_PROGRAM` is `iTerm2` or `WezTerm`. @@ -37,11 +37,10 @@ if you are using a terminal that doesn't support the default image protocol, `te if images do not appear correctly, you can manually override the image protocol in your `config.toml` file. - +```toml title="config.toml" +[interface.image_viewer] +protocol = "Auto" +``` available `protocol` values are: `"Auto"`, `"TGP"`, `"ITerm2"`, `"Sixel"`, `"Halfcell"`, and `"Unicode"`. @@ -49,14 +48,27 @@ available `protocol` values are: `"Auto"`, `"TGP"`, `"ITerm2"`, `"Sixel"`, `"Hal this lets you configure the maximum width and height of image previews in pixels. by default, `rovr` sets these values to `1920` (width) and `1080` (height). - - -you can choose to disable this by setting either value to `0`, which means the image preview will use the image as-is. - -### known issues - -- some low-resolution images will look horrible, I'm not really sure why that happens, but it might be due to an unnecessary up-scaling attempt by `textual-image` +```toml title="config.toml" +[interface.image_viewer] +# used for all images +max_size = [4000, 4000] + +[interface.font_preview] +# used for previewing fonts +max_size = [1000, 1000] +font_size = 50 +``` + +### image resampling + +when resampling, sometimes the image can appear pixelated. to solve this, you can set the image resampling to a higher type + +```toml title="config.toml" +[interface.image_viewer] +resampling = "nearest" # lowest quality, you see pixels +resampling = "box" # similar to nearest +resampling = "bilinear" # recommended usually +resampling = "hamming" +resampling = "bicubic" +resampling = "lanczos" # best quality, but most time +``` diff --git a/docs/src/content/docs/features/image-previews.mdx b/docs/src/content/docs/features/image-previews.mdx index f21eee47..47e49960 100644 --- a/docs/src/content/docs/features/image-previews.mdx +++ b/docs/src/content/docs/features/image-previews.mdx @@ -37,11 +37,10 @@ if you are using a terminal that doesn't support the default image protocol, `te if images do not appear correctly, you can manually override the image protocol in your `config.toml` file. - +```toml title="config.toml" +[interface.image_viewer] +protocol = "Auto" +``` available `image_protocol` values are: `"Auto"`, `"TGP"`, `"Sixel"`, `"Halfcell"`, and `"Unicode"`. @@ -49,14 +48,27 @@ available `image_protocol` values are: `"Auto"`, `"TGP"`, `"Sixel"`, `"Halfcell" this lets you configure the maximum width and height of image previews in pixels. by default, `rovr` sets these values to `1920` (width) and `1080` (height). - - -you can choose to disable this by setting either value to `0`, which means the image preview will use the image as-is. - -### known issues - -- some low-resolution images will look horrible, I'm not really sure why that happens, but it might be due to an unnecessary up-scaling attempt by `textual-image` +```toml title="config.toml" +[interface.image_viewer] +# used for all images +max_size = [4000, 4000] + +[interface.font_preview] +# used for previewing fonts +max_size = [1000, 1000] +font_size = 50 +``` + +### image resampling + +when resampling, sometimes the image can appear pixelated. to solve this, you can set the image resampling to a higher type + +```toml title="config.toml" +[interface.image_viewer] +resampling = "nearest" # lowest quality, you see pixels +resampling = "box" # similar to nearest +resampling = "bilinear" # recommended usually +resampling = "hamming" +resampling = "bicubic" +resampling = "lanczos" # best quality, but most time +``` diff --git a/src/rovr/components/iterm2_image.py b/src/rovr/components/iterm2_image.py index ce1eb3ef..bca53607 100644 --- a/src/rovr/components/iterm2_image.py +++ b/src/rovr/components/iterm2_image.py @@ -34,7 +34,10 @@ def is_supported() -> bool: return bool( sys.__stdout__ and sys.__stdout__.isatty() - and os.environ.get("TERM_PROGRAM") in ("iTerm2", "WezTerm") + and ( + os.environ.get("TERM_PROGRAM").lower() == "wezterm" + or os.environ.get("TERM_PROGRAM").lower().startswith("iterm") + ) ) @@ -97,6 +100,9 @@ class ITerm2Image(BaseImage, Renderable=_NoopRenderable): @override @BaseImage.image.setter def image(self, value: StrOrBytesPath | IO[bytes] | PILImage.Image | None) -> None: + # I'm not really sure on the context behind this line of code + # However, I will be keeping it since Sixel renderer also does + # this, and this is more similar to Sixel and TGP super(__class__, type(self)).image.fset(self, value) self.refresh(recompose=True) diff --git a/src/rovr/config/schema.json b/src/rovr/config/schema.json index bb990189..e7208ccb 100644 --- a/src/rovr/config/schema.json +++ b/src/rovr/config/schema.json @@ -312,11 +312,11 @@ "description": "The resampling method to use when resizing images. This is only applicable when the image exceeds the maximum size specified in `max_size`.", "enum": [ "nearest", - "lanczos", + "box", "bilinear", + "hamming", "bicubic", - "box", - "hamming" + "lanczos" ] } }