diff --git a/viu_media/assets/scripts/fzf/preview.py b/viu_media/assets/scripts/fzf/preview.py index eb692fd..e90f27f 100644 --- a/viu_media/assets/scripts/fzf/preview.py +++ b/viu_media/assets/scripts/fzf/preview.py @@ -3,18 +3,16 @@ # FZF Preview Script Template # # This script is a template. The placeholders in curly braces, like {NAME} -# are dynamically filled by python using .replace() +# are dynamically filled by python using .replace() during runtime. -from pathlib import Path -from hashlib import sha256 -import subprocess import os import shutil +import subprocess import sys -from rich.console import Console -from rich.rule import Rule +from hashlib import sha256 +from pathlib import Path -# dynamically filled variables +# --- Template Variables (Injected by Python) --- PREVIEW_MODE = "{PREVIEW_MODE}" IMAGE_CACHE_DIR = Path("{IMAGE_CACHE_DIR}") INFO_CACHE_DIR = Path("{INFO_CACHE_DIR}") @@ -24,192 +22,264 @@ SEPARATOR_COLOR = "{SEPARATOR_COLOR}" PREFIX = "{PREFIX}" SCALE_UP = "{SCALE_UP}" == "True" -TITLE = sys.argv[1] +# --- Arguments --- +# sys.argv[1] is usually the raw line from FZF (the anime title/key) +TITLE = sys.argv[1] if len(sys.argv) > 1 else "" KEY = """{KEY}""" KEY = KEY + "-" if KEY else KEY -hash = f"{PREFIX}-{sha256((KEY + TITLE).encode('utf-8')).hexdigest()}" +# Generate the hash to find the cached files +hash_id = f"{PREFIX}-{sha256((KEY + TITLE).encode('utf-8')).hexdigest()}" + + +def get_terminal_dimensions(): + """ + Determine the available dimensions (cols x lines) for the preview window. + Prioritizes FZF environment variables. + """ + fzf_cols = os.environ.get("FZF_PREVIEW_COLUMNS") + fzf_lines = os.environ.get("FZF_PREVIEW_LINES") + + if fzf_cols and fzf_lines: + return int(fzf_cols), int(fzf_lines) + + # Fallback to stty if FZF vars aren't set (unlikely in preview) + try: + rows, cols = ( + subprocess.check_output( + ["stty", "size"], text=True, stderr=subprocess.DEVNULL + ) + .strip() + .split() + ) + return int(cols), int(rows) + except Exception: + return 80, 24 + + +def which(cmd): + """Alias for shutil.which""" + return shutil.which(cmd) + + +def render_kitty(file_path, width, height, scale_up): + """Render using the Kitty Graphics Protocol (kitten/icat).""" + # 1. Try 'kitten icat' (Modern) + # 2. Try 'icat' (Legacy/Alias) + # 3. Try 'kitty +kitten icat' (Fallback) + + cmd = [] + if which("kitten"): + cmd = ["kitten", "icat"] + elif which("icat"): + cmd = ["icat"] + elif which("kitty"): + cmd = ["kitty", "+kitten", "icat"] + + if not cmd: + return False + + # Build Arguments + args = [ + "--clear", + "--transfer-mode=memory", + "--unicode-placeholder", + "--stdin=no", + f"--place={width}x{height}@0x0", + ] + + if scale_up: + args.append("--scale-up") + + args.append(file_path) + + subprocess.run(cmd + args, stdout=sys.stdout, stderr=sys.stderr) + return True + + +def render_sixel(file_path, width, height): + """ + Render using Sixel. + Prioritizes 'chafa' for Sixel as it handles text-cell sizing better than img2sixel. + """ + + # Option A: Chafa (Best for Sixel sizing) + if which("chafa"): + # Chafa automatically detects Sixel support if terminal reports it, + # but we force it here if specifically requested via logic flow. + subprocess.run( + ["chafa", "-f", "sixel", "-s", f"{width}x{height}", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + + # Option B: img2sixel (Libsixel) + # Note: img2sixel uses pixels, not cells. We estimate 1 cell ~= 10px width, 20px height + if which("img2sixel"): + pixel_width = width * 10 + pixel_height = height * 20 + subprocess.run( + [ + "img2sixel", + f"--width={pixel_width}", + f"--height={pixel_height}", + file_path, + ], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + + return False + + +def render_iterm(file_path, width, height): + """Render using iTerm2 Inline Image Protocol.""" + if which("imgcat"): + subprocess.run( + ["imgcat", "-W", str(width), "-H", str(height), file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + + # Chafa also supports iTerm + if which("chafa"): + subprocess.run( + ["chafa", "-f", "iterm", "-s", f"{width}x{height}", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + return False + + +def render_timg(file_path, width, height): + """Render using timg (supports half-blocks, quarter-blocks, sixel, kitty, etc).""" + if which("timg"): + subprocess.run( + ["timg", f"-g{width}x{height}", "--upscale", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + return False + + +def render_chafa_auto(file_path, width, height): + """ + Render using Chafa in auto mode. + It supports Sixel, Kitty, iTerm, and various unicode block modes. + """ + if which("chafa"): + subprocess.run( + ["chafa", "-s", f"{width}x{height}", file_path], + stdout=sys.stdout, + stderr=sys.stderr, + ) + return True + return False def fzf_image_preview(file_path: str): - # Environment variables from fzf - FZF_PREVIEW_COLUMNS = os.environ.get("FZF_PREVIEW_COLUMNS") - FZF_PREVIEW_LINES = os.environ.get("FZF_PREVIEW_LINES") - FZF_PREVIEW_TOP = os.environ.get("FZF_PREVIEW_TOP") - KITTY_WINDOW_ID = os.environ.get("KITTY_WINDOW_ID") - GHOSTTY_BIN_DIR = os.environ.get("GHOSTTY_BIN_DIR") - PLATFORM = os.environ.get("PLATFORM") + """ + Main dispatch function to choose the best renderer. + """ + cols, lines = get_terminal_dimensions() - # Compute terminal dimensions - dim = ( - f"{FZF_PREVIEW_COLUMNS}x{FZF_PREVIEW_LINES}" - if FZF_PREVIEW_COLUMNS and FZF_PREVIEW_LINES - else "x" - ) + # Heuristic: Reserve 1 line for prompt/status if needed, though FZF handles this. + # Some renderers behave better with a tiny bit of padding. + width = cols + height = lines - if dim == "x": - try: - rows, cols = ( - subprocess.check_output( - ["stty", "size"], text=True, stderr=subprocess.DEVNULL - ) - .strip() - .split() + # --- 1. Check Explicit Configuration --- + + if IMAGE_RENDERER == "icat" or IMAGE_RENDERER == "system-kitty": + if render_kitty(file_path, width, height, SCALE_UP): + return + + elif IMAGE_RENDERER == "sixel" or IMAGE_RENDERER == "system-sixels": + if render_sixel(file_path, width, height): + return + + elif IMAGE_RENDERER == "imgcat": + if render_iterm(file_path, width, height): + return + + elif IMAGE_RENDERER == "timg": + if render_timg(file_path, width, height): + return + + elif IMAGE_RENDERER == "chafa": + if render_chafa_auto(file_path, width, height): + return + + # --- 2. Auto-Detection / Fallback Strategy --- + + # If explicit failed or set to 'auto'/'system-default', try detecting environment + + # Ghostty / Kitty Environment + if os.environ.get("KITTY_WINDOW_ID") or os.environ.get("GHOSTTY_BIN_DIR"): + if render_kitty(file_path, width, height, SCALE_UP): + return + + # iTerm Environment + if os.environ.get("TERM_PROGRAM") == "iTerm.app": + if render_iterm(file_path, width, height): + return + + # Try standard tools in order of quality/preference + if render_kitty(file_path, width, height, SCALE_UP): + return # Try kitty just in case + if render_sixel(file_path, width, height): + return + if render_timg(file_path, width, height): + return + if render_chafa_auto(file_path, width, height): + return + + print("⚠️ No suitable image renderer found (icat, chafa, timg, img2sixel).") + + +def fzf_text_info_render(): + """Renders the text-based info via the cached python script.""" + from rich.console import Console + from rich.rule import Rule + + console = Console(force_terminal=True, color_system="truecolor") + console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) + + if PREVIEW_MODE == "text" or PREVIEW_MODE == "full": + preview_info_path = INFO_CACHE_DIR / f"{hash_id}.py" + if preview_info_path.exists(): + subprocess.run( + [sys.executable, str(preview_info_path), HEADER_COLOR, SEPARATOR_COLOR] ) - dim = f"{cols}x{rows}" - except Exception: - dim = "80x24" + else: + console.print("📝 Loading details...", style="dim") - # Adjust dimension if icat not used and preview area fills bottom of screen - if ( - IMAGE_RENDERER != "icat" - and not KITTY_WINDOW_ID - and FZF_PREVIEW_TOP - and FZF_PREVIEW_LINES + +def main(): + # 1. Image Preview + if (PREVIEW_MODE == "image" or PREVIEW_MODE == "full") and ( + PREFIX not in ("character", "review", "airing-schedule") ): - try: - term_rows = int( - subprocess.check_output(["stty", "size"], text=True).split()[0] - ) - if int(FZF_PREVIEW_TOP) + int(FZF_PREVIEW_LINES) == term_rows: - dim = f"{FZF_PREVIEW_COLUMNS}x{int(FZF_PREVIEW_LINES) - 1}" - except Exception: - pass - - # Helper to run commands - def run(cmd): - subprocess.run(cmd, stdout=sys.stdout, stderr=sys.stderr) - - def command_exists(cmd): - return shutil.which(cmd) is not None - - # ICAT / KITTY path - if IMAGE_RENDERER == "icat" and not GHOSTTY_BIN_DIR: - icat_cmd = None - if command_exists("kitten"): - icat_cmd = ["kitten", "icat"] - elif command_exists("icat"): - icat_cmd = ["icat"] - elif command_exists("kitty"): - icat_cmd = ["kitty", "icat"] - - if icat_cmd: - run( - icat_cmd - + [ - "--clear", - "--transfer-mode=memory", - "--unicode-placeholder", - "--stdin=no", - f"--place={dim}@0x0", - file_path, - ] - ) + preview_image_path = IMAGE_CACHE_DIR / f"{hash_id}.png" + if preview_image_path.exists(): + fzf_image_preview(str(preview_image_path)) + print() # Spacer else: - print("No icat-compatible viewer found (kitten/icat/kitty)") + print("🖼️ Loading image...") - elif GHOSTTY_BIN_DIR: - try: - cols = int(FZF_PREVIEW_COLUMNS or "80") - 1 - lines = FZF_PREVIEW_LINES or "24" - dim = f"{cols}x{lines}" - except Exception: - pass - - if command_exists("kitten"): - run( - [ - "kitten", - "icat", - "--clear", - "--transfer-mode=memory", - "--unicode-placeholder", - "--stdin=no", - f"--place={dim}@0x0", - file_path, - ] - ) - elif command_exists("icat"): - run( - [ - "icat", - "--clear", - "--transfer-mode=memory", - "--unicode-placeholder", - "--stdin=no", - f"--place={dim}@0x0", - file_path, - ] - ) - elif command_exists("chafa"): - run(["chafa", "-s", dim, file_path]) - - elif command_exists("chafa"): - # Platform specific rendering - if PLATFORM == "android": - run(["chafa", "-s", dim, file_path]) - elif PLATFORM == "windows": - run(["chafa", "-f", "sixel", "-s", dim, file_path]) - else: - run(["chafa", "-s", dim, file_path]) - print() - - elif command_exists("imgcat"): - width, height = dim.split("x") - run(["imgcat", "-W", width, "-H", height, file_path]) - - else: - print( - "⚠️ Please install a terminal image viewer (icat, kitten, imgcat, or chafa)." - ) + # 2. Text Info Preview + fzf_text_info_render() -def fzf_text_preview(file_path: str): - from base64 import standard_b64encode - - def serialize_gr_command(**cmd): - payload = cmd.pop("payload", None) - cmd = ",".join(f"{k}={v}" for k, v in cmd.items()) - ans = [] - w = ans.append - w(b"\033_G") - w(cmd.encode("ascii")) - if payload: - w(b";") - w(payload) - w(b"\033\\") - return b"".join(ans) - - def write_chunked(**cmd): - data = standard_b64encode(cmd.pop("data")) - while data: - chunk, data = data[:4096], data[4096:] - m = 1 if data else 0 - sys.stdout.buffer.write(serialize_gr_command(payload=chunk, m=m, **cmd)) - sys.stdout.flush() - cmd.clear() - - with open(file_path, "rb") as f: - write_chunked(a="T", f=100, data=f.read()) - - -console = Console(force_terminal=True, color_system="truecolor") -if (PREVIEW_MODE == "image" or PREVIEW_MODE == "full") and ( - PREFIX not in ("character", "review", "airing-schedule") -): - preview_image_path = IMAGE_CACHE_DIR / f"{hash}.png" - if preview_image_path.exists(): - fzf_image_preview(str(preview_image_path)) - print() - else: - print("🖼️ Loading image...") - -console.print(Rule(style=f"rgb({SEPARATOR_COLOR})")) -if PREVIEW_MODE == "text" or PREVIEW_MODE == "full": - preview_info_path = INFO_CACHE_DIR / f"{hash}.py" - if preview_info_path.exists(): - subprocess.run( - [sys.executable, str(preview_info_path), HEADER_COLOR, SEPARATOR_COLOR] - ) - else: - console.print("📝 Loading details...") +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + pass + except Exception as e: + print(f"Preview Error: {e}")