fix: use pyte terminal emulator for screenshot rendering
Replaces simple carriage return handling with pyte terminal emulator to properly interpret all ANSI escape sequences including cursor positioning. This fixes the tmux status bar 'creeping up' issue in screenshots. Adds pyte dependency to pyproject.toml. Resolves TODO item #2.
This commit is contained in:
@@ -21,6 +21,7 @@ importlib-metadata = ">=6.0.0"
|
||||
httpx = ">=0.27.0"
|
||||
tomli = { version = "^2.0.1", python = "<3.11" }
|
||||
pyyaml = "^6.0.0"
|
||||
pyte = "^0.8.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.0.0"
|
||||
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
import pyte
|
||||
from aiohttp import WSMsgType, web
|
||||
from rich.ansi import AnsiDecoder
|
||||
from rich.console import Console
|
||||
@@ -107,26 +108,17 @@ def _rewrite_svg_fonts(svg: str) -> str:
|
||||
return svg
|
||||
|
||||
|
||||
def _apply_carriage_returns(text: str) -> list[str]:
|
||||
"""Interpret \r as 'return to start of line' (overwrite), not a newline.
|
||||
def _apply_carriage_returns(text: str, width: int = 80, height: int = 24) -> list[str]:
|
||||
"""Use pyte terminal emulator to properly interpret ANSI escape sequences.
|
||||
|
||||
This prevents terminals that redraw a single line (progress bars, prompts) from
|
||||
expanding into many duplicate lines in screenshots.
|
||||
This handles cursor positioning, screen clearing, and other terminal control
|
||||
codes that cause issues like tmux status bars "creeping up" in screenshots.
|
||||
"""
|
||||
|
||||
lines: list[str] = []
|
||||
current: list[str] = []
|
||||
for ch in text:
|
||||
if ch == "\r":
|
||||
current.clear()
|
||||
elif ch == "\n":
|
||||
lines.append("".join(current))
|
||||
current.clear()
|
||||
else:
|
||||
current.append(ch)
|
||||
if current:
|
||||
lines.append("".join(current))
|
||||
return lines
|
||||
screen = pyte.Screen(width, height)
|
||||
stream = pyte.Stream(screen)
|
||||
stream.feed(text)
|
||||
# Return lines from the display, stripping trailing whitespace
|
||||
return [line.rstrip() for line in screen.display]
|
||||
|
||||
|
||||
class LocalServer:
|
||||
@@ -490,9 +482,9 @@ class LocalServer:
|
||||
height = DISCONNECT_RESIZE[1]
|
||||
height = max(5, min(200, height))
|
||||
|
||||
lines = _apply_carriage_returns(ansi_text)
|
||||
if len(lines) > height:
|
||||
ansi_text = "\n".join(lines[-height:]) + "\n"
|
||||
# Use pyte terminal emulator to get clean screen state
|
||||
lines = _apply_carriage_returns(ansi_text, width, height)
|
||||
ansi_text = "\n".join(lines)
|
||||
|
||||
now = asyncio.get_event_loop().time()
|
||||
ttl = self._get_screenshot_cache_ttl(route_key, now)
|
||||
|
||||
@@ -111,8 +111,24 @@ class TestLocalServerHelpers:
|
||||
"""Tests for LocalServer helper methods."""
|
||||
|
||||
def test_apply_carriage_returns_overwrites_line(self):
|
||||
text = "hello\rworld\nnext"
|
||||
assert _apply_carriage_returns(text) == ["world", "next"]
|
||||
text = "hello\rworld\r\nnext"
|
||||
# pyte terminal emulator interprets CR properly - overwrites hello with world
|
||||
lines = _apply_carriage_returns(text, width=80, height=24)
|
||||
# First line should have "world" (overwritten), second line "next"
|
||||
assert lines[0] == "world"
|
||||
assert lines[1] == "next"
|
||||
|
||||
def test_apply_carriage_returns_handles_cursor_positioning(self):
|
||||
# Simulate tmux-style cursor positioning to row 5, column 1 (\x1b[5;1H)
|
||||
# Then clear to end of line (\x1b[K) and write new content
|
||||
# Use \r\n for proper line endings
|
||||
text = "line1\r\nline2\r\nline3\r\nline4\r\nline5\x1b[5;1H\x1b[Kupdated"
|
||||
lines = _apply_carriage_returns(text, width=80, height=10)
|
||||
# Line 5 (index 4) should be overwritten with "updated"
|
||||
assert lines[4] == "updated"
|
||||
# Previous lines should remain
|
||||
assert lines[0] == "line1"
|
||||
assert lines[1] == "line2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch):
|
||||
@@ -755,7 +771,7 @@ class TestLocalServerMoreCoverage:
|
||||
|
||||
captured = {"len": None}
|
||||
|
||||
def apply_cr(text: str):
|
||||
def apply_cr(text: str, width: int = 80, height: int = 24):
|
||||
captured["len"] = len(text)
|
||||
return ["x"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user