166 lines
5.5 KiB
Python
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)
|