LVPWB/lvpwb/render.py

469 lines
19 KiB
Python

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']