diff --git a/docs/src/content/docs/dev/features/image-previews.mdx b/docs/src/content/docs/dev/features/image-previews.mdx
index 56b81e58..f997b857 100644
--- a/docs/src/content/docs/dev/features/image-previews.mdx
+++ b/docs/src/content/docs/dev/features/image-previews.mdx
@@ -13,23 +13,23 @@ 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 | ❌ | ✅ |
-
-> the `iterm2 inline images` protocol is not supported. see [lnqs/textual-image #68](https://github.com/lnqs/textual-image/issues/68) for more details.
+| 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`.
### configuring the image protocol
@@ -37,26 +37,38 @@ 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"`.
+available `protocol` values are: `"Auto"`, `"TGP"`, `"ITerm2"`, `"Sixel"`, `"Halfcell"`, and `"Unicode"`.
### configuring the image size
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 56b81e58..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/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..bca53607
--- /dev/null
+++ b/src/rovr/components/iterm2_image.py
@@ -0,0 +1,211 @@
+"""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").lower() == "wezterm"
+ or os.environ.get("TERM_PROGRAM").lower().startswith("iterm")
+ )
+ )
+
+
+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:
+ # 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)
+
+ 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..e7208ccb 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",
@@ -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"
]
}
}
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()