268 lines
8.8 KiB
Python
268 lines
8.8 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 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 letterbox an image into exactly ``width`` x ``height`` cells.
|
|
|
|
Scale to fit, never crop. Square album art fills the box either way, but a
|
|
wide station wordmark (Bassdrive's logo is 3:1) used to be centre-cropped
|
|
down to a zoomed slice of its middle. The padding stays transparent, so
|
|
the mosaic behind the artwork shows through instead of a black bar.
|
|
"""
|
|
try:
|
|
from PIL import Image, ImageOps
|
|
|
|
with Image.open(BytesIO(data)) as source:
|
|
# A PNG with transparency is drawn *over* the mosaic, which reads
|
|
# as noise behind a logo; composite those onto black instead.
|
|
has_alpha = source.mode in ("RGBA", "LA", "PA", "RGBa", "La") or (
|
|
"transparency" in source.info
|
|
)
|
|
image = source.convert("RGBA")
|
|
cell_width, cell_height = cell_size()
|
|
target = (
|
|
max(1, width * cell_width),
|
|
max(1, height * cell_height),
|
|
)
|
|
fitted = ImageOps.contain(image, target, method=Image.Resampling.LANCZOS)
|
|
background = (0, 0, 0, 255) if has_alpha else (0, 0, 0, 0)
|
|
canvas = Image.new("RGBA", target, background)
|
|
canvas.alpha_composite(
|
|
fitted,
|
|
(
|
|
(target[0] - fitted.width) // 2,
|
|
(target[1] - fitted.height) // 2,
|
|
),
|
|
)
|
|
return canvas
|
|
except Exception:
|
|
return None
|