from __future__ import annotations import os import re import select import sys import textwrap from functools import lru_cache from pathlib import Path from typing import Any import lvpwb as app def color(text: str, code: str, enabled: bool = True) -> str: return f"{code}{text}{app.RESET}" if enabled else text def short(text: str, width: int) -> str: if len(text) <= width: return text if width < 2: return text[:width] return text[:width - 1] + "…" def match_lines( match: dict[str, Any], number: int, width: int, flash: bool = False, promo_selected: bool = False, promo_expanded: bool = False, ) -> list[str]: box_width = max(36, min(width - 4, 100)) inner = box_width - 4 heading = f"Match {number}" + (f" — {match['title']}" if match.get("title") else "") heading = app.short(heading, inner) heading_text = f" {heading} " top_fill = max(0, box_width - 2 - len(heading_text)) lines = [f"┌{heading_text}{'─' * top_fill}┐", f"│ {match['type'].center(inner)} │"] sides = match["sides"] winner = match.get("winner_side") if len(sides) == 2: left_width = max(10, (inner - 8) // 2) right_width = inner - left_width - 6 labels: list[str] = [] for side_number, side in enumerate(sides): label = " & ".join(side) if winner is not None: if side_number == winner: label = "WIN!" if flash else app.short(label, left_width if side_number == 0 else right_width) label = app.color(label, app.RED) else: label = "LOSE" if flash else app.short(label, left_width if side_number == 0 else right_width) label = app.color(label, app.YELLOW) else: label = app.short(label, left_width if side_number == 0 else right_width) labels.append(label) left = labels[0] right = labels[1] # ANSI color sequences are zero-width; pad using the original label lengths. left_plain_width = left_width right_plain_width = right_width lines.append(f"│ {left}{' ' * max(0, left_plain_width - app.visible_length(left))} VS " f"{right}{' ' * max(0, right_plain_width - app.visible_length(right))} │") else: pieces: list[str] = [] for side_number, side in enumerate(sides): label = " & ".join(side) if winner is not None: if side_number == winner: label = "WIN!" if flash else label label = app.color(label, app.RED) else: label = "LOSE" if flash else label label = app.color(label, app.YELLOW) pieces.append(label) separator = " VS " required = sum(app.visible_length(piece) for piece in pieces) + len(separator) * (len(pieces) - 1) if required > inner and len(pieces) > 1: name_budget = max(1, (inner - len(separator) * (len(pieces) - 1)) // len(pieces)) pieces = [app.short_ansi(piece, name_budget) for piece in pieces] joined = separator.join(pieces) padding = max(0, inner - app.visible_length(joined)) lines.append(f"│ {' ' * (padding // 2)}{joined}{' ' * (padding - padding // 2)} │") status = "COMPLETE" if winner is not None else "UP NEXT" if match.get("title_defense"): division = str(match.get("ranking_division") or "Championship").upper() vacant = str(match.get("title", "")).startswith("Vacant ") status = f"{'VACANT ' if vacant else ''}{division} TITLE MATCH · {status}" else: status = f"NON-TITLE MATCH · {status}" if match.get("finish"): status += f" · {match['finish']}" if match.get("match_time_minutes") is not None: status += f" · {float(match['match_time_minutes']):g} min" if match.get("rating") is not None: status += f" · {float(match['rating']):.1f}%" status = app.short(status, inner) if match.get("promo"): if promo_expanded: control = "▼ Promo (Enter to collapse)" else: preview = " / ".join(str(match["promo"]).splitlines()) control = app.short(f"▶ Promo: {preview}", inner) control_line = f"│ {control:<{inner}} │" lines.append(app.color(control_line, "\033[7m", enabled=promo_selected)) if promo_expanded: wrap_width = max(8, inner - 2) for turn in str(match["promo"]).splitlines(): wrapped_turn = textwrap.wrap( turn, width=wrap_width, break_long_words=True, break_on_hyphens=False ) or [""] for wrapped in wrapped_turn: lines.append(f"│ {wrapped:<{wrap_width}} │") status_text = f" {status} " status_padding = max(0, box_width - 2 - len(status_text)) left_padding = status_padding // 2 right_padding = status_padding - left_padding lines.append(f"└{'─' * left_padding}{status_text}{'─' * right_padding}┘") return lines ANSI_RE = re.compile(r"\033\[[0-9;]*m") def visible_length(text: str) -> int: return len(app.ANSI_RE.sub("", text)) def short_ansi(text: str, width: int) -> str: plain = app.ANSI_RE.sub("", text) if len(plain) <= width: return text # Multi-way names are shortened only in the uncolored portion; retain the # match color by applying it to the shortened result. code = "" if text.startswith(app.RED): code = app.RED elif text.startswith(app.YELLOW): code = app.YELLOW return app.color(app.short(plain, width), code, bool(code)) @lru_cache(maxsize=8) def _render_image(path: Path, max_columns: int, max_rows: int) -> tuple[list[str], int]: """Render an image as ANSI true-color half-blocks when Pillow is installed.""" try: from PIL import Image except ImportError: return [], 0 try: with Image.open(path) as source: image = source.convert("RGBA") image.thumbnail((max_columns, max_rows * 2), Image.Resampling.LANCZOS) image_width, image_height = image.size pixels = image.load() output: list[str] = [] for y in range(0, image_height, 2): row: list[str] = [] for x in range(image_width): top = pixels[x, y] bottom = pixels[x, y + 1] if y + 1 < image_height else (0, 0, 0, 255) # Composite transparent logo pixels onto the terminal background. top = tuple(round(top[i] * top[3] / 255) for i in range(3)) bottom = tuple(round(bottom[i] * bottom[3] / 255) for i in range(3)) row.append(f"\033[38;2;{top[0]};{top[1]};{top[2]}m" f"\033[48;2;{bottom[0]};{bottom[1]};{bottom[2]}m▀") output.append("".join(row) + app.RESET) return output, image_width except Exception: return [], 0 def show_logo_path(show_name: str) -> Path | None: """Choose a centered show logo from the show name, ignoring case.""" normalized = show_name.casefold() if "smackdown" in normalized: return app.ROOT / "smackdown-logo-source.png" if re.search(r"\braw\b", normalized): return app.ROOT / "raw-is-war-logo.png" return None def header_lines(card: dict[str, Any], width: int) -> list[str]: card_federation = str(card.get("federation_name") or app.federation_name(card["game_id"])) is_wwf = app.is_wwf_federation(card_federation) if is_wwf: left, left_width = app._render_image(app.ROOT / "wwfcomlogo.gif", 23, 6) else: left, left_width = [], 0 logo_path = app.show_logo_path(card["show"]) if logo_path: center, center_width = app._render_image(logo_path, 38, 6) if not center: brand = "SMACKDOWN!" if logo_path.name == "smackdown-logo-source.png" else "RAW IS WAR" center = [app.color(brand, app.CYAN)] center_width = len(brand) else: center, center_width = [], 0 if is_wwf and not left: left = [app.color("WWF.com", app.RED)] left_width = 7 row_count = max(len(left), len(center), 2) if width < 62: title = card.get("game_title", app.game_title(card["game_id"])) heading = f"{app.color('WWF.com', app.RED)} {app.color(title, app.CYAN)}" if is_wwf \ else app.color(title, app.CYAN) return [heading, f"{card['date']} · {app.show_location_text(card)}", ""] left_x = 1 if left: center_x = max(left_x + left_width + 3, (width - center_width) // 2) else: center_x = (width - center_width) // 2 date_text = card["date"] date_x = width - len(date_text) - 2 output: list[str] = [] for index in range(row_count): segments: list[str] = [] cursor = 0 if index < len(left): segments.append(" " * (left_x - cursor) + left[index]) cursor = left_x + left_width if index < len(center): if center_x < cursor: center_x = cursor + 2 segments.append(" " * (center_x - cursor) + center[index]) cursor = center_x + center_width if index == 0 and date_x > cursor: segments.append(" " * (date_x - cursor) + app.color(date_text, app.DIM)) output.append("".join(segments)) output.append(app.color(app.short(app.show_location_text(card), width - 2), app.CYAN)) return output def render_card_sections( card: dict[str, Any], flash: bool = False, selected_promo: int | None = None, expanded_promos: set[int] | None = None, width: int | None = None, ) -> tuple[list[str], list[str], dict[int, tuple[int, int]]]: width = width or app.shutil.get_terminal_size((100, 28)).columns expanded_promos = expanded_promos or set() header = app.header_lines(card, width) body: list[str] = [] promo_spans: dict[int, tuple[int, int]] = {} for match_index in reversed(range(len(card["matches"]))): index = match_index + 1 match = card["matches"][match_index] start = len(body) block = app.match_lines( match, index, width, flash=flash, promo_selected=(match_index == selected_promo), promo_expanded=(match_index in expanded_promos), ) if match.get("promo"): control_row = start + 3 promo_spans[match_index] = (control_row, start + len(block) - 1) body.extend(block) body.append(app.color(f"Saved: {card['path']}", app.DIM)) ppv_rating = app.calculate_ppv_rating(card) if ppv_rating is not None: body.append(app.color(f"WWF PPV Rating: {ppv_rating:.1f}%", app.CYAN)) instructions = "↑/↓ select promo · Enter expand/collapse · PgUp/PgDn scroll · q close" body.append(app.color(app.short(instructions, max(1, width - 2)), app.DIM)) return header, body, promo_spans def render_card_lines( card: dict[str, Any], flash: bool = False, selected_promo: int | None = None, expanded_promos: set[int] | None = None, ) -> list[str]: header, body, _ = app.render_card_sections( card, flash=flash, selected_promo=selected_promo, expanded_promos=expanded_promos ) return header + body def render_card(card: dict[str, Any], flash: bool = False) -> str: return "\n".join(app.render_card_lines(card, flash=flash)) def viewport_frame( header: list[str], body: list[str], height: int, scroll_offset: int ) -> tuple[list[str], int, int]: """Keep the header fixed while clipping the scrollable card body to the screen.""" height = max(1, height) header_count = min(len(header), max(0, height - 1)) fixed_header = header[:header_count] body_capacity = max(1, height - header_count) max_offset = max(0, len(body) - body_capacity) offset = min(max(0, scroll_offset), max_offset) visible_body = body[offset:offset + body_capacity] frame = fixed_header + visible_body frame.extend([""] * (height - len(frame))) return frame, offset, body_capacity def keep_promo_visible( scroll_offset: int, promo_span: tuple[int, int] | None, body_capacity: int, body_length: int, ) -> int: if promo_span is None: return min(scroll_offset, max(0, body_length - body_capacity)) start, end = promo_span span_length = end - start if span_length <= body_capacity: if start < scroll_offset: scroll_offset = start elif end > scroll_offset + body_capacity: scroll_offset = end - body_capacity elif start < scroll_offset or start >= scroll_offset + body_capacity: scroll_offset = start return min(max(0, scroll_offset), max(0, body_length - body_capacity)) def write_changed_lines(previous: list[str], current: list[str], stream: Any = None) -> None: """Update only changed screen rows; static branding and card text stay put.""" stream = stream or sys.stdout changed = False for index, line in enumerate(current): if index >= len(previous) or line != previous[index]: stream.write(f"\033[{index + 1};1H\033[2K{line}") changed = True if changed: stream.flush() def interactive_card_frame( card: dict[str, Any], terminal_width: int, terminal_height: int, flash: bool = False, selected_promo: int | None = None, expanded_promos: set[int] | None = None, scroll_offset: int = 0, keep_selection_visible: bool = False, ) -> tuple[list[str], int, int, dict[int, tuple[int, int]]]: header, body, promo_spans = app.render_card_sections( card, flash=flash, selected_promo=selected_promo, expanded_promos=expanded_promos, width=terminal_width, ) _, _, body_capacity = app.viewport_frame(header, body, terminal_height, scroll_offset) if keep_selection_visible and selected_promo is not None: scroll_offset = app.keep_promo_visible( scroll_offset, promo_spans.get(selected_promo), body_capacity, len(body) ) frame, scroll_offset, body_capacity = app.viewport_frame( header, body, terminal_height, scroll_offset ) return frame, scroll_offset, body_capacity, promo_spans def read_view_key(fd: int) -> str: """Read one key from a cbreak terminal, translating common navigation keys.""" first = os.read(fd, 1) if first in (b"\r", b"\n"): return "enter" if first in (b"q", b"Q"): return "quit" if first in (b"j", b"J"): return "down" if first in (b"k", b"K"): return "up" if first != b"\x1b": return "other" ready, _, _ = select.select([fd], [], [], 0.05) if not ready: return "quit" if os.read(fd, 1) != b"[": return "other" ready, _, _ = select.select([fd], [], [], 0.05) if not ready: return "other" code = os.read(fd, 1) if code == b"A": return "up" if code == b"B": return "down" if code in (b"5", b"6"): ready, _, _ = select.select([fd], [], [], 0.05) if ready and os.read(fd, 1) == b"~": return "pageup" if code == b"5" else "pagedown" return "other" def view_card(card: dict[str, Any]) -> None: if not sys.stdin.isatty() or not sys.stdout.isatty(): print("\033[2J\033[H" + app.render_card(card)) input("\nPress Enter to continue...") return try: import termios import tty fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) tty.setcbreak(fd) try: terminal = app.shutil.get_terminal_size((100, 28)) promo_indices = [index for index in reversed(range(len(card["matches"]))) if card["matches"][index].get("promo")] selected_promo = promo_indices[0] if promo_indices else None expanded_promos: set[int] = set() scroll_offset = 0 has_results = any(match.get("winner_side") is not None for match in card["matches"]) flash = False frame, scroll_offset, body_capacity, promo_spans = app.interactive_card_frame( card, terminal.columns, terminal.lines, flash=flash, selected_promo=selected_promo, expanded_promos=expanded_promos, scroll_offset=scroll_offset, keep_selection_visible=True, ) # Paint the static header and collapsed card once. Subsequent frames # update changed rows only, with the header pinned above the viewport. sys.stdout.write("\033[2J\033[H" + "\n".join(frame)) sys.stdout.flush() while True: ready, _, _ = select.select([sys.stdin], [], [], 0.65) key = app.read_view_key(fd) if ready else "" keep_selection_visible = False if key == "quit": break elif key in ("up", "down") and promo_indices: current = promo_indices.index(selected_promo) if selected_promo in promo_indices else 0 delta = -1 if key == "up" else 1 selected_promo = promo_indices[(current + delta) % len(promo_indices)] keep_selection_visible = True elif key == "enter" and selected_promo is not None: if selected_promo in expanded_promos: expanded_promos.remove(selected_promo) else: expanded_promos.add(selected_promo) keep_selection_visible = True elif key == "pageup": scroll_offset -= max(1, body_capacity // 2) elif key == "pagedown": scroll_offset += max(1, body_capacity // 2) elif not key and has_results: flash = not flash elif key: continue next_frame, scroll_offset, body_capacity, promo_spans = app.interactive_card_frame( card, terminal.columns, terminal.lines, flash=flash, selected_promo=selected_promo, expanded_promos=expanded_promos, scroll_offset=scroll_offset, keep_selection_visible=keep_selection_visible, ) app.write_changed_lines(frame, next_frame) frame = next_frame finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) sys.stdout.write("\033[0m\033[2J\033[H") sys.stdout.flush() except (ImportError, OSError, ValueError): print("\033[2J\033[H" + app.render_card(card)) input("\nPress Enter to continue...") __all__ = ['ANSI_RE', '_render_image', 'color', 'header_lines', 'interactive_card_frame', 'keep_promo_visible', 'match_lines', 'read_view_key', 'render_card', 'render_card_lines', 'render_card_sections', 'short', 'short_ansi', 'show_logo_path', 'view_card', 'viewport_frame', 'visible_length', 'write_changed_lines']