diff --git a/textual_image/_terminal.py b/textual_image/_terminal.py index fd78230..678ce9e 100644 --- a/textual_image/_terminal.py +++ b/textual_image/_terminal.py @@ -4,6 +4,8 @@ import os import sys from contextlib import contextmanager +from re import search +from shutil import get_terminal_size from types import SimpleNamespace from typing import Iterator, NamedTuple, cast @@ -19,6 +21,10 @@ logger = logging.getLogger(__name__) +# VT340 sizes +FALLBACK_SIZE = (10, 20) + + class TerminalError(Exception): """Error thrown on failing terminal operations.""" @@ -35,7 +41,7 @@ class CellSize(NamedTuple): def get_cell_size() -> CellSize: - """Get size information from the terminal. + """Get the terminal's character cell size in pixels. This function is querying the terminal only once. For any call after the first, a cached result is returned. @@ -49,8 +55,7 @@ def get_cell_size() -> CellSize: if hasattr(get_cell_size, "_result"): return cast("CellSize", getattr(get_cell_size, "_result")) - width = 0 - height = 0 + size = None if sys.__stdout__.isatty(): # Try to get the cell size via ioctl @@ -58,37 +63,51 @@ def get_cell_size() -> CellSize: rows, columns, screen_width, screen_height = get_tiocgwinsz() width = int(screen_width / columns) height = int(screen_height / rows) + + if width and height: + size = (width, height) except OSError as e: logger.debug("Failed to get cell size via ioctl, falling back to escape sequence", exc_info=e) - if sys.__stdout__.isatty() and (height == 0 or width == 0): # Didn't work, let's try to do it via escape sequence - try: - with capture_terminal_response("\x1b[", "t", 0.1) as response: - sys.__stdout__.write("\x1b[16t") - sys.__stdout__.flush() + if not size: + # CSI 16 t: report character cell size in pixels. + # Response: ESC [ 6 ; height ; width t + response = query_tty("\x1b[16t") + + if response and (found := search(r"\x1b\[6;(\d+);(\d+)t", response)): + height, width = map(int, found.groups()) + size = (width, height) - sequence = response.sequence[len("\x1b[") : -len("t")] - _, height, width = [int(v) for v in sequence.split(";")] - except (TerminalError, TimeoutError) as e: - logger.warning("Failed to get cell size via escape sequence, assuming VT340 sizes", exc_info=e) + # Didn't work, try an alternate escape code sequence + if not size: + # CSI 14 t: report terminal dimensions in pixels + # Response: ESC [ 4 ; pixel_height ; pixel_width t + response = query_tty("\x1b[14t") - if height == 0 or width == 0: - # Try environment variables (set by textual-serve for web terminals) + if response and (found := search(r"\x1b\[4;(\d+);(\d+)t", response)): + height, width = map(int, found.groups()) + + # Note: this can also be done via + # CSI 18 t response: ESC [ 8 ; rows ; columns t + # query_tty("\x1b[18t") + columns, rows = get_terminal_size() + + size = (round(width / columns), round(height / rows)) + + # Try environment variables (set by textual-serve for web terminals) + if not size: match os.environ: case { "TEXTUAL_CELL_WIDTH": str(width_str), "TEXTUAL_CELL_HEIGHT": str(height_str), } if width_str.isdigit() and height_str.isdigit(): - width = int(width_str) - height = int(height_str) + size = (int(width_str), int(height_str)) - if height == 0 or width == 0: - # Still didn't work, use VT340 sizes as default - width = 10 - height = 20 + if not size: + size = FALLBACK_SIZE - cell_size = CellSize(width, height) + cell_size = CellSize(*size) setattr(get_cell_size, "_result", cell_size) return cell_size @@ -133,5 +152,16 @@ def capture_terminal_response( raise TerminalError("Unexpected response from terminal") +def query_tty(query: str, timeout: float = 0.1) -> str | None: + """Query the terminal using an escape code.""" + try: + with capture_terminal_response("\x1b[", "t", timeout) as response: + sys.__stdout__.write(query) + sys.__stdout__.flush() + return response.sequence + except (TimeoutError, TerminalError): + ... + + def prepare_terminal_sequence(data: str) -> str: return maybe_tmux_escape(data)