Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 51 additions & 21 deletions textual_image/_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -19,6 +21,10 @@
logger = logging.getLogger(__name__)


# VT340 sizes
FALLBACK_SIZE = (10, 20)


class TerminalError(Exception):
"""Error thrown on failing terminal operations."""

Expand All @@ -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.

Expand All @@ -49,46 +55,59 @@ 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
try:
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

Expand Down Expand Up @@ -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)
Loading