469 lines
19 KiB
Python
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']
|