Skip to content
Open
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
27 changes: 21 additions & 6 deletions tests/renderable/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,37 @@

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):
module = reload(textual_image.renderable)
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)
Expand Down
81 changes: 81 additions & 0 deletions tests/renderable/test_kitty.py
Original file line number Diff line number Diff line change
@@ -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()
66 changes: 66 additions & 0 deletions tests/widget/test_kitty.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions textual_image/demo/renderable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -29,6 +32,7 @@

RENDERING_METHODS = {
"auto": AutoRenderable,
"kitty": KittyRenderable,
"tgp": TGPRenderable,
"sixel": SixelRenderable,
"halfcell": HalfcellRenderable,
Expand Down
11 changes: 8 additions & 3 deletions textual_image/demo/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@
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"


RENDERING_METHODS = {
"auto": AutoImage,
"kitty": KittyImage,
"tgp": TGPImage,
"sixel": SixelImage,
"halfcell": HalfcellImage,
Expand Down Expand Up @@ -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


Expand Down
20 changes: 15 additions & 5 deletions textual_image/renderable/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
Expand All @@ -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",
]
Loading
Loading