Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 46 additions & 34 deletions docs/src/content/docs/dev/features/image-previews.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,62 @@ 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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### configuring the image protocol

if you are using a terminal that doesn't support the default image protocol, `textual-image` will fall back to a `half-cell` representation.

if images do not appear correctly, you can manually override the image protocol in your `config.toml` file.

<Code
code={`[settings]\nimage_protocol = "Sixel"`}
lang="toml"
title="config.toml"
/>
```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).

<Code
code={`[interface]\nmax_preview_image_width = 1920\nmax_preview_image_height = 1080`}
lang="toml"
title="config.toml"
/>

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
```
44 changes: 28 additions & 16 deletions docs/src/content/docs/features/image-previews.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Code
code={`[settings]\nimage_protocol = "Sixel"`}
lang="toml"
title="config.toml"
/>
```toml title="config.toml"
[interface.image_viewer]
protocol = "Auto"
```

available `image_protocol` values are: `"Auto"`, `"TGP"`, `"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).

<Code
code={`[interface]\nmax_preview_image_width = 1920\nmax_preview_image_height = 1080`}
lang="toml"
title="config.toml"
/>

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
```
3 changes: 3 additions & 0 deletions src/rovr/classes/config.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,7 @@ r""" minimum: 1 """
_RovrConfigInterfaceImageViewerProtocol = (
Literal["Auto"]
| Literal["TGP"]
| Literal["ITerm2"]
| Literal["Sixel"]
| Literal["Halfcell"]
| Literal["Unicode"]
Expand All @@ -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"
Expand Down
211 changes: 211 additions & 0 deletions src/rovr/components/iterm2_image.py
Original file line number Diff line number Diff line change
@@ -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")
)
)
Comment on lines +34 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate files:"
git ls-files | rg '(^|/)(iterm2_image\.py|test_iterm2_image\.py)$|src/rovr/components/iterm2_image.py|tests/test_iterm2_image.py' || true

echo
echo "Production implementation outline:"
if [ -f src/rovr/components/iterm2_image.py ]; then
  wc -l src/rovr/components/iterm2_image.py
  ast-grep outline src/rovr/components/iterm2_image.py || true
  echo "--- production ---"
  cat -n src/rovr/components/iterm2_image.py
fi

echo
echo "Test implementation outline:"
if [ -f tests/test_iterm2_image.py ]; then
  wc -l tests/test_iterm2_image.py
  ast-grep outline tests/test_iterm2_image.py || true
  echo "--- tests ---"
  cat -n tests/test_iterm2_image.py
fi

echo
echo "Usages / references:"
rg -n 'is_supported\(|ITerm2Image|Term_PROGRAM|iterm2|WezTerm|TERM_PROGRAM' src tests 2>/dev/null || true

Repository: NSPC911/rovr

Length of output: 12276


🌐 Web query:

iTerm2 TERM_PROGRAM iTerm.app man iTerm

💡 Result:

The environment variable TERM_PROGRAM is set by iTerm2 to the value iTerm.app [1][2][3]. This variable is used to identify the terminal emulator currently in use, allowing shell configuration files (such as.bashrc or.zshrc) and scripts to detect the environment and conditionally apply terminal-specific settings, themes, or behaviors [2][4][5]. While the query mentions man iTerm, please note that there is no standard manual page for the terminal emulator itself named iTerm. The official documentation for iTerm2 is available online at the iTerm2 website rather than through the local system man command [6][7]. Summary of usage: 1. Identification: You can verify this in your terminal by running echo $TERM_PROGRAM [3][4]. 2. Scripting: It is commonly used in conditional logic within shell profiles, for example: if [ "$TERM_PROGRAM" = "iTerm.app" ]; then # Apply iTerm2-specific configurations fi [3][5] 3. Limitations: Because environment variables are not always passed to remote hosts (e.g., via SSH), relying on TERM_PROGRAM for remote sessions may not be reliable [1][4]. For further assistance, the official iTerm2 documentation is the primary resource for features, scripting, and advanced usage [6][7].

Citations:


Detect iTerm2 using its advertised TERM_PROGRAM value.

TERM_PROGRAM is advertised as iTerm.app, so the current check can return False in iTerm2 and leave image selection on another renderer. Use iTerm.app in both the runtime guard and the supporting test assertions.

Proposed fix
-        and os.environ.get("TERM_PROGRAM") in ("iTerm2", "WezTerm")
+        and os.environ.get("TERM_PROGRAM") in ("iTerm.app", "WezTerm")
-@pytest.mark.parametrize("terminal", ["iTerm2", "WezTerm"])
+@pytest.mark.parametrize("terminal", ["iTerm.app", "WezTerm"])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return bool(
sys.__stdout__
and sys.__stdout__.isatty()
and os.environ.get("TERM_PROGRAM") in ("iTerm2", "WezTerm")
)
return bool(
sys.__stdout__
and sys.__stdout__.isatty()
and os.environ.get("TERM_PROGRAM") in ("iTerm.app", "WezTerm")
)
📍 Affects 2 files
  • src/rovr/components/iterm2_image.py#L34-L38 (this comment)
  • tests/test_iterm2_image.py#L22-L29
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rovr/components/iterm2_image.py` around lines 34 - 38, The iTerm2 runtime
detection in iterm2_image.py should recognize the advertised TERM_PROGRAM value
by replacing the iTerm2 entry with iTerm.app in the guard around the visible
stdout check. Update the corresponding assertions in tests/test_iterm2_image.py
lines 22-29 to use iTerm.app as well; no other renderer behavior should change.



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)
Comment on lines +100 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The image setter uses an unusual super(__class__, type(self)) pattern that likely doesn't call the intended base property setter

Using super(__class__, type(self)).image.fset(self, value) means super is bound to the class, not the instance, and effectively resolves as super(ITerm2Image, ITerm2Image). This is non-idiomatic and may bypass or misinvoke BaseImage.image. Prefer calling the base setter explicitly, e.g. BaseImage.image.fset(self, value), then self.refresh(recompose=True) to keep the behavior clear and correct.

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
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
return Style(bgcolor=color.rich_color)
Loading
Loading