Files
beefy-player/somafm_tui/artwork.py
T
beefcarver 6eb0c6b6fe fix(artwork): ignore TMUX/ZELLIJ/STY values that mean 'not set'
Some shells and wrappers export ZELLIJ=0 or TMUX=0 outside a multiplexer.
A plain truthiness check treated that as 'inside tmux/zellij' and
downgraded iTerm2/Kitty to the ANSI mosaic, so real artwork silently
disappeared. Only meaningful values now count.
2026-10-30 18:43:16 -04:00

250 lines
7.9 KiB
Python

"""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 SomaFM album art
(or channel logo) and render it directly, with the mosaic kept 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 ``SOMAFM_IMAGES=0`` to force the ANSI fallback, or ``SOMAFM_IMAGES=tgp`` /
``SOMAFM_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("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.CACHE_DIR / "artwork" / 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", ""),
"SOMAFM_IMAGES": 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.CACHE_DIR / "artwork"),
}
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