"""ON AIR artwork: real terminal images over an always-present ANSI mosaic.""" from __future__ import annotations import base64 import contextlib import itertools import os import random import sys from io import BytesIO from typing import TYPE_CHECKING, cast from rich.console import Group, RenderableType from rich.control import Control from rich.segment import ControlType, Segment from rich.style import Style from rich.text import Text from textual.dom import NoScreen from textual.geometry import Region from textual.strip import Strip from textual.widget import Widget from .. import artwork from .common import art_lines _NULL_STYLE = Style() _NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),) if TYPE_CHECKING: from ..app import SomaFMApp _IMAGE_IDS = itertools.count(random.randrange(1, 2**31 - 1)) def _tgp(data: str) -> str: return f"\x1b_G{data}\x1b\\" def _terminal_wrap(sequence: str) -> str: """Wrap Kitty sequences for tmux DCS passthrough.""" term = os.environ.get("TERM", "") if os.environ.get("TMUX") or term.startswith(("screen", "tmux")): return ( "\x1bPtmux;\x1b" + sequence.replace("\x1b", "\x1b\x1b") + "\x1b\\" ) return sequence class KittyImage(Widget): """Direct Kitty graphics placement (no Unicode placeholders or probe).""" DEFAULT_CSS = """ KittyImage { width: 100%; height: 100%; } """ can_focus = False def __init__(self, image) -> None: super().__init__() self._image = image self._image_id: int | None = None self._delete_ids: list[int] = [] self._placed_key: tuple[int, int, int] | None = None @property def image(self): return self._image @image.setter def image(self, value) -> None: if self._image_id is not None and value is not self._image: self._delete_ids.append(self._image_id) self._image_id = None self._image = value self._placed_key = None self.refresh(layout=True) def _sequences(self, width: int, height: int) -> list[str]: key = (id(self._image), width, height) if self._image_id is not None and self._placed_key == key: return [] buffer = BytesIO() self._image.save(buffer, format="PNG") payload = base64.b64encode(buffer.getvalue()).decode("ascii") chunks = [payload[i : i + 4096] for i in range(0, len(payload), 4096)] commands: list[str] = [] for old_id in self._delete_ids: commands.append(_terminal_wrap(_tgp(f"a=d,i={old_id},q=2"))) self._delete_ids.clear() image_id = next(_IMAGE_IDS) for index, chunk in enumerate(chunks): more = 1 if index < len(chunks) - 1 else 0 commands.append( _terminal_wrap( _tgp(f"a=t,i={image_id},f=100,m={more},q=2;{chunk}") ) ) commands.append( _terminal_wrap( _tgp(f"a=p,i={image_id},c={width},r={height},C=1,q=2") ) ) self._image_id = image_id self._placed_key = key return commands def cleanup(self) -> None: ids = list(self._delete_ids) if self._image_id is not None: ids.append(self._image_id) self._image_id = None self._delete_ids.clear() self._placed_key = None stdout = sys.__stdout__ if stdout is None: return for image_id in ids: with contextlib.suppress(Exception): stdout.write( _terminal_wrap(_tgp(f"a=d,i={image_id},q=2")) ) with contextlib.suppress(Exception): stdout.flush() def render_lines(self, crop: Region) -> list[Strip]: empty = [Strip([]) for _ in range(max(0, crop.height))] if self._image is None: return empty try: if not self.screen.is_active: return empty except NoScreen: return empty width = self.content_size.width height = self.content_size.height if width <= 0 or height <= 0: return empty commands = self._sequences(width, height) if not commands: return empty try: visible = self.screen.find_widget(self).visible_region except NoScreen: return empty segments: list[Segment] = [ Segment( Control.move_to(visible.x, visible.y).segment.text, style=_NULL_STYLE, ) ] for command in commands: segments.append( Segment(command, style=_NULL_STYLE, control=_NULL_CONTROL) ) segments.append( Segment( Control.move_to(visible.right, visible.bottom).segment.text, style=_NULL_STYLE, ) ) last = Strip(segments, cell_length=max(1, crop.width)) return [Strip([]) for _ in range(max(0, crop.height - 1))] + [last] class ITerm2Image(Widget): """Inline image via OSC 1337, injected through Textual's render_lines(). WezTerm and iTerm2 both implement this natively. ``doNotMoveCursor=1`` keeps the terminal cursor sane; the widget then restores it to the bottom-right of the region. """ DEFAULT_CSS = """ ITerm2Image { width: 100%; height: 100%; } """ can_focus = False def __init__(self, image) -> None: super().__init__() self._image = image self._cache_key: tuple[int, int, int] | None = None self._cache_data = "" @property def image(self): return self._image @image.setter def image(self, value) -> None: self._image = value self._cache_key = None self._cache_data = "" self.refresh(layout=True) def _sequence(self, width: int, height: int) -> str: key = (id(self._image), width, height) if key == self._cache_key: return self._cache_data buffer = BytesIO() self._image.save(buffer, format="PNG") payload = base64.b64encode(buffer.getvalue()).decode("ascii") data = ( "\x1b]1337;File=inline=1;" f"width={width};height={height};" f"preserveAspectRatio=0:{payload}\x07" ) self._cache_key = key self._cache_data = data return data def render_lines(self, crop: Region) -> list[Strip]: empty = [Strip([]) for _ in range(max(0, crop.height))] if self._image is None: return empty try: if not self.screen.is_active: return empty except NoScreen: return empty width = self.content_size.width height = self.content_size.height if width <= 0 or height <= 0: return empty try: visible = self.screen.find_widget(self).visible_region except NoScreen: return empty segments = [ Segment( Control.move_to(visible.x, visible.y).segment.text, style=_NULL_STYLE, ), Segment( self._sequence(width, height), style=_NULL_STYLE, control=_NULL_CONTROL, ), Segment( Control.move_to(visible.right, visible.bottom).segment.text, style=_NULL_STYLE, ), ] last = Strip(segments, cell_length=max(1, crop.width)) return [Strip([]) for _ in range(max(0, crop.height - 1))] + [last] class ArtPane(Widget): """Mosaic background plus an optional protocol-backed image overlay. The mosaic is always rendered by this widget. If the terminal image protocol silently fails (or an image is transparent), the artwork area is still not blank. """ can_focus = False def __init__(self) -> None: super().__init__() self._image_widget: Widget | None = None self._last_data: bytes | None = None self._last_size: tuple[int, int] = (0, 0) # ------------------------------------------------------------------ # # mosaic background # ------------------------------------------------------------------ # def render(self) -> RenderableType: app = cast("SomaFMApp", self.app) station = app.current if station is None: return Text("") width = max(1, self.size.width) height = max(1, self.size.height) song = station.now_playing key = f"{station.id}|{song.artist if song else ''}|{song.title if song else ''}" return Group(*art_lines(key, width, height)) # ------------------------------------------------------------------ # # image overlay # ------------------------------------------------------------------ # def on_mount(self) -> None: self.sync_image() def on_resize(self, event) -> None: self.refresh() if self.display: self.sync_image() def on_unmount(self) -> None: self._drop_image_widget() def sync_image(self, *, force: bool = False) -> None: # Keep the mosaic behind the image in sync with the current track. self.refresh() app = cast("SomaFMApp", self.app) station = app.current data = app.artwork_bytes width = max(1, self.size.width) height = max(1, self.size.height) if station is not None and data and artwork.supported(): if ( not force and self._image_widget is not None and self._last_data is data and self._last_size == (width, height) ): return fitted = artwork.fit_image(data, width, height) if fitted is not None: self._show_image(fitted, data, width, height) return self._show_fallback() def _show_image(self, image, data: bytes, width: int, height: int) -> None: if self._image_widget is None: if artwork.PROTOCOL == "tgp": widget_class = KittyImage elif artwork.PROTOCOL == "iterm2": widget_class = ITerm2Image else: self._show_fallback() return try: widget = widget_class(image) except Exception: self._show_fallback() return widget.add_class("art-image") self._image_widget = widget self.mount(widget) else: self._image_widget.image = image self._last_data = data self._last_size = (width, height) def _show_fallback(self) -> None: self._drop_image_widget() def _drop_image_widget(self) -> None: widget = self._image_widget self._image_widget = None self._last_data = None self._last_size = (0, 0) if widget is None: return with contextlib.suppress(Exception): widget.cleanup() with contextlib.suppress(Exception): widget.image = None widget.remove()