Files
v2a/v2a-player/v2a_player/terminal.py
Exil Productions 0cb370f8ec Initial Commit
2025-12-19 20:18:56 +01:00

166 lines
5.5 KiB
Python

import os
import shutil
import sys
import math
from typing import Tuple, Optional
def get_terminal_size() -> Tuple[int, int]:
size = shutil.get_terminal_size()
return (size.columns, size.lines)
def calculate_scaled_dimensions(
src_width: int,
src_height: int,
max_width: Optional[int] = None,
max_height: Optional[int] = None,
) -> Tuple[int, int]:
if max_width is None or max_height is None:
max_width, max_height = get_terminal_size()
max_height = max_height - 2
if src_width <= max_width and src_height <= max_height:
return (src_width, src_height)
width_scale = max_width / (2.0 * src_width)
height_scale = max_height / src_height
scale = min(width_scale, height_scale)
scaled_width = int(2.0 * scale * src_width)
scaled_height = int(scale * src_height)
scaled_width = max(1, scaled_width)
scaled_height = max(1, scaled_height)
return (scaled_width, scaled_height)
def calculate_centering_offset(
src_width: int,
src_height: int,
container_width: int,
container_height: int,
) -> Tuple[int, int]:
x = (container_width - src_width) // 2
y = (container_height - src_height) // 2
return (max(0, x), max(0, y))
def ansi_color_fg(index: int) -> str:
return f"\x1b[38;5;{index}m"
def ansi_color_bg(index: int) -> str:
return f"\x1b[48;5;{index}m"
def ansi_reset() -> str:
return "\x1b[0m"
def ansi_clear_screen() -> str:
return "\x1b[2J\x1b[H"
def ansi_move_cursor(row: int, col: int) -> str:
return f"\x1b[{row};{col}H"
def ansi_hide_cursor() -> str:
return "\x1b[?25l"
def ansi_show_cursor() -> str:
return "\x1b[?25h"
def render_half_block(top_color: int, bottom_color: int) -> str:
if top_color == bottom_color:
return f"{ansi_color_fg(top_color)}{ansi_reset()}"
else:
return f"{ansi_color_fg(top_color)}{ansi_color_bg(bottom_color)}{ansi_reset()}"
class TerminalRenderer:
def __init__(self):
self.term_width, self.term_height = get_terminal_size()
self.scaled_width = 0
self.scaled_height = 0
self.src_width = 0
self.src_height = 0
self.offset_x = 0
self.offset_y = 0
def update_layout(self, src_width: int, src_height: int):
self.src_width = src_width
self.src_height = src_height
self.scaled_width, self.scaled_height = calculate_scaled_dimensions(
src_width, src_height, self.term_width, self.term_height
)
self.offset_x, self.offset_y = calculate_centering_offset(
self.scaled_width, self.scaled_height, self.term_width, self.term_height
)
def check_resize(self) -> bool:
new_width, new_height = get_terminal_size()
if new_width != self.term_width or new_height != self.term_height:
self.term_width, self.term_height = new_width, new_height
if self.src_width > 0 and self.src_height > 0:
self.update_layout(self.src_width, self.src_height)
return True
return False
def render_frame(self, frame_pixel_pairs, frame_width: int, frame_height: int) -> str:
if (self.scaled_width, self.scaled_height) != (frame_width, frame_height):
return self._render_scaled_frame(frame_pixel_pairs, frame_width, frame_height)
else:
return self._render_exact_frame(frame_pixel_pairs, frame_width, frame_height)
def _render_exact_frame(self, pixel_pairs, width: int, height: int) -> str:
result = []
for y in range(height):
row = self.offset_y + 1 + y
col = self.offset_x + 1
result.append(ansi_move_cursor(row, col))
line_parts = []
for x in range(width):
idx = y * width + x
top, bottom = pixel_pairs[idx]
line_parts.append(render_half_block(top, bottom))
result.append("".join(line_parts))
return "".join(result)
def _render_scaled_frame(self, pixel_pairs, src_width: int, src_height: int) -> str:
dst_width, dst_height = self.scaled_width, self.scaled_height
result = []
for dy in range(dst_height):
row = self.offset_y + 1 + dy
col = self.offset_x + 1
result.append(ansi_move_cursor(row, col))
sy = int(dy * src_height / dst_height)
line_parts = []
for dx in range(dst_width):
sx = int(dx * src_width / dst_width)
idx = sy * src_width + sx
top, bottom = pixel_pairs[idx]
line_parts.append(render_half_block(top, bottom))
result.append("".join(line_parts))
return "".join(result)
def prepare_display(self) -> str:
return ansi_clear_screen() + ansi_hide_cursor()
def restore_display(self) -> str:
return ansi_show_cursor() + ansi_clear_screen()
def frame_prefix(self) -> str:
return ansi_move_cursor(self.offset_y + 1, self.offset_x + 1)
def clear_video_area(self) -> str:
if self.scaled_width <= 0 or self.scaled_height <= 0:
return ""
result = []
for row in range(self.offset_y + 1, self.offset_y + self.scaled_height + 1):
result.append(ansi_move_cursor(row, self.offset_x + 1))
result.append(ansi_reset())
result.append(" " * self.scaled_width)
return "".join(result)