"""Terminal artwork support. The TUI draws a procedural ANSI mosaic when the terminal cannot display real images. On terminals with a graphics protocol we fetch the station's album art (or channel logo) and render it directly, with the mosaic behind it: * Kitty -> Terminal Graphics Protocol (direct image placement) * iTerm2 / WezTerm -> native iTerm2 inline images (OSC 1337) The protocols are implemented directly; there is no terminal capability probe and no extra terminal input, so nothing leaks into the shell at exit. Set ``BEEFY_IMAGES=0`` to force the ANSI fallback, or ``BEEFY_IMAGES=tgp`` / ``BEEFY_IMAGES=iterm2`` to force a protocol. """ from __future__ import annotations import hashlib import os import shutil import subprocess from collections.abc import Mapping from io import BytesIO import httpx from . import config _OFF_VALUES = {"0", "off", "no", "none", "false"} _PROTOCOL_VALUES = {"tgp", "iterm2"} def _env_flag(env: Mapping[str, str], name: str) -> bool: """True when an environment variable is set to a meaningful value. Multiplexers are inconsistent here (``TMUX`` is a path, ``STY`` a session name) and some wrappers export ``ZELLIJ=0``/``TMUX=0`` when they are *not* running, so a plain truthiness check would wrongly downgrade a perfectly good iTerm2/Kitty to the ANSI mosaic. """ value = (env.get(name) or "").strip() return bool(value) and value.lower() not in _OFF_VALUES def detect_protocol(environ: Mapping[str, str] | None = None) -> str | None: """Return ``"tgp"``, ``"iterm2"`` or ``None`` for the current terminal.""" env = os.environ if environ is None else environ if "NO_COLOR" in env: return None override = ( env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES") or "" ).strip().lower() if override in _OFF_VALUES: return None if override in _PROTOCOL_VALUES: return override term = (env.get("TERM") or "").lower() program = (env.get("TERM_PROGRAM") or "").lower() if ( env.get("KITTY_WINDOW_ID") or env.get("KITTY_PID") or "kitty" in term or "kitty" in program ): # Kitty can receive TGP through tmux passthrough; we wrap the # sequences for tmux. The mosaic remains behind the image if the # multiplexer blocks graphics. return "tgp" # Kitty inside tmux may not inherit TERM=xterm-kitty/KITTY_*. Ask tmux # what the outer terminal is; TGP is wrapped for tmux passthrough. if _env_flag(env, "TMUX") and shutil.which("tmux"): try: result = subprocess.run( ["tmux", "display-message", "-p", "#{client_termname}"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=1.0, check=False, ) if "kitty" in result.stdout.lower(): return "tgp" except (OSError, subprocess.SubprocessError): pass # iTerm2/WezTerm inside tmux would need their own passthrough wrapper, so # default to the mosaic there. if ( _env_flag(env, "TMUX") or _env_flag(env, "ZELLIJ") or _env_flag(env, "STY") or term.startswith("screen") or term.startswith("tmux") or program in {"tmux", "screen"} ): return None if ( program in {"wezterm", "iterm.app"} or env.get("WEZTERM_EXECUTABLE") or env.get("ITERM_SESSION_ID") ): # WezTerm and iTerm2 both implement the iTerm2 inline-image protocol # natively; WezTerm's TGP placeholders are less reliable inside Textual. return "iterm2" return None PROTOCOL = detect_protocol() def supported() -> bool: return PROTOCOL in ("tgp", "iterm2") def cell_size() -> tuple[int, int]: """Terminal cell size in pixels (used to crop artwork to its cell box).""" import sys width = os.environ.get("TEXTUAL_CELL_WIDTH", "") height = os.environ.get("TEXTUAL_CELL_HEIGHT", "") if width.isdigit() and height.isdigit() and int(width) > 0 and int(height) > 0: return int(width), int(height) if sys.platform != "win32": try: import fcntl import struct import termios stdout = sys.__stdout__ if stdout is not None and stdout.isatty(): packed = fcntl.ioctl( stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8 ) _, _, pixel_width, pixel_height = struct.unpack("HHHH", packed) terminal = os.get_terminal_size(stdout.fileno()) if ( terminal.columns > 0 and terminal.lines > 0 and pixel_width > 0 and pixel_height > 0 ): return ( max(1, pixel_width // terminal.columns), max(1, pixel_height // terminal.lines), ) except (OSError, ValueError): pass return 10, 20 def _cache_path(url: str): digest = hashlib.sha1(url.encode("utf-8")).hexdigest() return config.ARTWORK_CACHE_DIR / digest def load_cached(url: str) -> bytes | None: try: data = _cache_path(url).read_bytes() except OSError: return None return data or None def save_cached(url: str, data: bytes) -> None: try: path = _cache_path(url) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(path.name + ".tmp") tmp.write_bytes(data) os.replace(tmp, path) except OSError: pass def diagnostics() -> dict[str, object]: """Return a small dict describing image support for --diagnose-images.""" import sys from importlib.metadata import PackageNotFoundError, version def pkg_version(name: str) -> str: try: return version(name) except PackageNotFoundError: return "not installed" env = os.environ return { "tty": bool(sys.__stdout__ is not None and sys.__stdout__.isatty()), "TERM": env.get("TERM", ""), "TERM_PROGRAM": env.get("TERM_PROGRAM", ""), "KITTY_WINDOW_ID": env.get("KITTY_WINDOW_ID", ""), "WEZTERM_EXECUTABLE": env.get("WEZTERM_EXECUTABLE", ""), "ITERM_SESSION_ID": env.get("ITERM_SESSION_ID", ""), "TMUX": env.get("TMUX", ""), "ZELLIJ": env.get("ZELLIJ", ""), "BEEFY_IMAGES": env.get("BEEFY_IMAGES") or env.get("SOMAFM_IMAGES", ""), "NO_COLOR": "set" if "NO_COLOR" in env else "", "protocol": PROTOCOL, "supported": supported(), "cell_size": cell_size(), "pillow": pkg_version("pillow"), "cache": str(config.ARTWORK_CACHE_DIR), } async def fetch(client: httpx.AsyncClient, url: str) -> bytes | None: """Download and validate an image, caching the bytes on disk.""" if not url.startswith(("http://", "https://")): return None try: response = await client.get(url, headers={"Accept": "image/*,*/*"}) response.raise_for_status() data = response.content if not data: return None from PIL import Image with Image.open(BytesIO(data)) as image: image.verify() save_cached(url, data) return data except (httpx.HTTPError, OSError, ValueError): return None def fit_image(data: bytes, width: int, height: int): """Decode and center-crop an image to exactly ``width`` x ``height`` cells.""" try: from PIL import Image, ImageOps with Image.open(BytesIO(data)) as source: image = source.convert("RGBA") cell_width, cell_height = cell_size() target = ( max(1, width * cell_width), max(1, height * cell_height), ) return ImageOps.fit( image, target, method=Image.Resampling.LANCZOS, centering=(0.5, 0.5), ) except Exception: return None