-
Notifications
You must be signed in to change notification settings - Fork 26
feat: add iTerm2 inline image support #310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: NSPC911/rovr Length of output: 12276 🌐 Web query:
💡 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
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
Suggested change
📍 Affects 2 files
🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): The Using |
||||||||||||||||||||||
| 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 | ||||||||||||||||||||||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||
| return Style(bgcolor=color.rich_color) | ||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.