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"
|
httpx = ">=0.27.0"
|
||||||
tomli = { version = "^2.0.1", python = "<3.11" }
|
tomli = { version = "^2.0.1", python = "<3.11" }
|
||||||
pyyaml = "^6.0.0"
|
pyyaml = "^6.0.0"
|
||||||
|
pyte = "^0.8.0"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pytest = "^8.0.0"
|
pytest = "^8.0.0"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import pyte
|
||||||
from aiohttp import WSMsgType, web
|
from aiohttp import WSMsgType, web
|
||||||
from rich.ansi import AnsiDecoder
|
from rich.ansi import AnsiDecoder
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@@ -107,26 +108,17 @@ def _rewrite_svg_fonts(svg: str) -> str:
|
|||||||
return svg
|
return svg
|
||||||
|
|
||||||
|
|
||||||
def _apply_carriage_returns(text: str) -> list[str]:
|
def _apply_carriage_returns(text: str, width: int = 80, height: int = 24) -> list[str]:
|
||||||
"""Interpret \r as 'return to start of line' (overwrite), not a newline.
|
"""Use pyte terminal emulator to properly interpret ANSI escape sequences.
|
||||||
|
|
||||||
This prevents terminals that redraw a single line (progress bars, prompts) from
|
This handles cursor positioning, screen clearing, and other terminal control
|
||||||
expanding into many duplicate lines in screenshots.
|
codes that cause issues like tmux status bars "creeping up" in screenshots.
|
||||||
"""
|
"""
|
||||||
|
screen = pyte.Screen(width, height)
|
||||||
lines: list[str] = []
|
stream = pyte.Stream(screen)
|
||||||
current: list[str] = []
|
stream.feed(text)
|
||||||
for ch in text:
|
# Return lines from the display, stripping trailing whitespace
|
||||||
if ch == "\r":
|
return [line.rstrip() for line in screen.display]
|
||||||
current.clear()
|
|
||||||
elif ch == "\n":
|
|
||||||
lines.append("".join(current))
|
|
||||||
current.clear()
|
|
||||||
else:
|
|
||||||
current.append(ch)
|
|
||||||
if current:
|
|
||||||
lines.append("".join(current))
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
class LocalServer:
|
class LocalServer:
|
||||||
@@ -490,9 +482,9 @@ class LocalServer:
|
|||||||
height = DISCONNECT_RESIZE[1]
|
height = DISCONNECT_RESIZE[1]
|
||||||
height = max(5, min(200, height))
|
height = max(5, min(200, height))
|
||||||
|
|
||||||
lines = _apply_carriage_returns(ansi_text)
|
# Use pyte terminal emulator to get clean screen state
|
||||||
if len(lines) > height:
|
lines = _apply_carriage_returns(ansi_text, width, height)
|
||||||
ansi_text = "\n".join(lines[-height:]) + "\n"
|
ansi_text = "\n".join(lines)
|
||||||
|
|
||||||
now = asyncio.get_event_loop().time()
|
now = asyncio.get_event_loop().time()
|
||||||
ttl = self._get_screenshot_cache_ttl(route_key, now)
|
ttl = self._get_screenshot_cache_ttl(route_key, now)
|
||||||
|
|||||||
@@ -111,8 +111,24 @@ class TestLocalServerHelpers:
|
|||||||
"""Tests for LocalServer helper methods."""
|
"""Tests for LocalServer helper methods."""
|
||||||
|
|
||||||
def test_apply_carriage_returns_overwrites_line(self):
|
def test_apply_carriage_returns_overwrites_line(self):
|
||||||
text = "hello\rworld\nnext"
|
text = "hello\rworld\r\nnext"
|
||||||
assert _apply_carriage_returns(text) == ["world", "next"]
|
# 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
|
@pytest.mark.asyncio
|
||||||
async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch):
|
async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch):
|
||||||
@@ -755,7 +771,7 @@ class TestLocalServerMoreCoverage:
|
|||||||
|
|
||||||
captured = {"len": None}
|
captured = {"len": None}
|
||||||
|
|
||||||
def apply_cr(text: str):
|
def apply_cr(text: str, width: int = 80, height: int = 24):
|
||||||
captured["len"] = len(text)
|
captured["len"] = len(text)
|
||||||
return ["x"]
|
return ["x"]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user