From 707e8b7e316f8a6c8c9be3e80f260c2f9cfbe3ae Mon Sep 17 00:00:00 2001 From: Mortdecai Date: Sun, 29 Mar 2026 19:13:45 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20image=20rendering=20=E2=80=94=20kitty?= =?UTF-8?q?=20graphics,=20sixel,=20and=20ASCII=20art=20backends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kitty_workbench/image_renderer.py | 76 +++++++++++++++++++++++++++ src/kitty_workbench/tui.py | 6 ++- 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/kitty_workbench/image_renderer.py diff --git a/src/kitty_workbench/image_renderer.py b/src/kitty_workbench/image_renderer.py new file mode 100644 index 0000000..77703a7 --- /dev/null +++ b/src/kitty_workbench/image_renderer.py @@ -0,0 +1,76 @@ +"""Image rendering abstraction — kitty graphics, sixel, or ASCII art.""" + +from __future__ import annotations + +import base64 +import shutil +import subprocess +import sys +from pathlib import Path + + +def render_image_kitty(path: str) -> str: + """Return kitty graphics protocol escape sequence for the image.""" + data = Path(path).read_bytes() + b64 = base64.standard_b64encode(data).decode() + chunks = [b64[i:i + 4096] for i in range(0, len(b64), 4096)] + escape = "" + for i, chunk in enumerate(chunks): + m = 1 if i < len(chunks) - 1 else 0 + if i == 0: + escape += f"\033_Ga=T,f=100,m={m};{chunk}\033\\" + else: + escape += f"\033_Gm={m};{chunk}\033\\" + return escape + + +def render_image_sixel(path: str) -> str: + """Convert image to sixel using chafa or img2sixel.""" + if shutil.which("chafa"): + result = subprocess.run( + ["chafa", "--format=sixel", "--size=80x40", str(path)], + capture_output=True, text=True, + ) + if result.returncode == 0: + return result.stdout + if shutil.which("img2sixel"): + result = subprocess.run( + ["img2sixel", "-w", "640", str(path)], + capture_output=True, text=True, + ) + if result.returncode == 0: + return result.stdout + return f"[Sixel unavailable — install chafa or libsixel. Image: {path}]" + + +def render_image_ascii(path: str) -> str: + """Convert image to ASCII/Unicode block art using chafa.""" + if shutil.which("chafa"): + result = subprocess.run( + ["chafa", "--size=60x30", str(path)], + capture_output=True, text=True, + ) + if result.returncode == 0: + return result.stdout + return f"[Image: {path}]" + + +def render_image(path: str, protocol: str) -> str: + """Render an image using the specified protocol. Returns terminal-ready string.""" + p = Path(path) + + if p.suffix.lower() == ".svg": + try: + import cairosvg + png_path = p.with_suffix(".png") + cairosvg.svg2png(url=str(p), write_to=str(png_path)) + path = str(png_path) + except ImportError: + return f"[SVG display requires cairosvg: pip install cairosvg. File: {path}]" + + if protocol == "kitty": + return render_image_kitty(path) + elif protocol == "sixel": + return render_image_sixel(path) + else: + return render_image_ascii(path) diff --git a/src/kitty_workbench/tui.py b/src/kitty_workbench/tui.py index d50ccc9..7a455ab 100644 --- a/src/kitty_workbench/tui.py +++ b/src/kitty_workbench/tui.py @@ -155,8 +155,10 @@ class KittWorkbenchApp(App): pane = self._get_pane(cmd.pane) if cmd.clear and not isinstance(pane, RichLog): pane.remove_children() - # Placeholder — image rendering added in Task 7 - pane.mount(Static(f"[Image: {cmd.path}]")) + + from kitty_workbench.image_renderer import render_image + rendered = render_image(cmd.path, self._image_protocol) + pane.mount(Static(rendered, markup=False)) def _handle_log(self, cmd: LogCmd) -> None: try: