Add custom SVG exporter, remove Rich from screenshot rendering
- Created svg_exporter.py with direct pyte-to-SVG rendering - Eliminates Rich's export_svg() quirks (clip path count mismatch) - Added 63 comprehensive tests for SVG exporter - Removed Rich imports from local_server.py, terminal_session.py, app_session.py, and cli.py - Replaced RichHandler with standard logging.basicConfig - Replaced @rich.repr.auto with standard __repr__ methods - Rich is no longer directly imported (still transitive via textual-serve) Bump version to 0.3.0
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "textual-webterm"
|
name = "textual-webterm"
|
||||||
version = "0.2.10"
|
version = "0.3.0"
|
||||||
description = "Serve terminal sessions over the web"
|
description = "Serve terminal sessions over the web"
|
||||||
authors = ["Will McGugan <will@textualize.io>"]
|
authors = ["Will McGugan <will@textualize.io>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from enum import Enum, auto
|
|||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import rich.repr
|
|
||||||
from importlib_metadata import version
|
from importlib_metadata import version
|
||||||
|
|
||||||
from . import constants
|
from . import constants
|
||||||
@@ -41,7 +40,6 @@ class ProcessState(Enum):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto(angular=True)
|
|
||||||
class AppSession(Session):
|
class AppSession(Session):
|
||||||
"""Runs a single app process."""
|
"""Runs a single app process."""
|
||||||
|
|
||||||
@@ -128,11 +126,9 @@ class AppSession(Session):
|
|||||||
"""Check if the app session is still running."""
|
"""Check if the app session is still running."""
|
||||||
return self._state == ProcessState.RUNNING
|
return self._state == ProcessState.RUNNING
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
def __repr__(self) -> str:
|
||||||
yield self.command
|
returncode = self._process.returncode if self._process else None
|
||||||
yield "id", self.session_id
|
return f"<AppSession {self.command!r} id={self.session_id!r} returncode={returncode}>"
|
||||||
if self._process is not None:
|
|
||||||
yield "returncode", self._process.returncode, None
|
|
||||||
|
|
||||||
async def open(self, width: int = 80, height: int = 24) -> None:
|
async def open(self, width: int = 80, height: int = 24) -> None:
|
||||||
"""Open the process."""
|
"""Open the process."""
|
||||||
|
|||||||
@@ -10,17 +10,15 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
from importlib_metadata import version
|
from importlib_metadata import version
|
||||||
from rich.logging import RichHandler
|
|
||||||
|
|
||||||
from . import constants
|
from . import constants
|
||||||
from .local_server import LocalServer
|
from .local_server import LocalServer
|
||||||
|
|
||||||
FORMAT = "%(message)s"
|
FORMAT = "%(asctime)s %(levelname)s %(message)s"
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level="DEBUG" if constants.DEBUG else "INFO",
|
level="DEBUG" if constants.DEBUG else "INFO",
|
||||||
format=FORMAT,
|
format=FORMAT,
|
||||||
datefmt="[%X]",
|
datefmt="%X",
|
||||||
handlers=[RichHandler(show_path=False)],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log = logging.getLogger("textual-webterm")
|
log = logging.getLogger("textual-webterm")
|
||||||
|
|||||||
@@ -5,19 +5,14 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import signal
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import WSMsgType, web
|
from aiohttp import WSMsgType, web
|
||||||
from rich.console import Console
|
|
||||||
from rich.style import Style
|
|
||||||
from rich.text import Text
|
|
||||||
|
|
||||||
from . import constants
|
from . import constants
|
||||||
from .docker_stats import DockerStatsCollector, render_sparkline_svg
|
from .docker_stats import DockerStatsCollector, render_sparkline_svg
|
||||||
@@ -26,6 +21,7 @@ from .identity import generate
|
|||||||
from .poller import Poller
|
from .poller import Poller
|
||||||
from .session import SessionConnector
|
from .session import SessionConnector
|
||||||
from .session_manager import SessionManager
|
from .session_manager import SessionManager
|
||||||
|
from .svg_exporter import render_terminal_svg
|
||||||
from .types import Meta, RouteKey, SessionID
|
from .types import Meta, RouteKey, SessionID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -38,50 +34,6 @@ DEFAULT_TERMINAL_SIZE = (132, 45)
|
|||||||
SCREENSHOT_CACHE_SECONDS = 1.0
|
SCREENSHOT_CACHE_SECONDS = 1.0
|
||||||
SCREENSHOT_MAX_CACHE_SECONDS = 60.0
|
SCREENSHOT_MAX_CACHE_SECONDS = 60.0
|
||||||
|
|
||||||
SVG_MONO_FONT_STACK = (
|
|
||||||
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", '
|
|
||||||
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", '
|
|
||||||
'"DejaVu Sans Mono", "Courier New", monospace'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Map pyte color names to Rich-compatible names
|
|
||||||
# pyte uses different naming conventions than Rich for some colors
|
|
||||||
PYTE_TO_RICH_COLOR = {
|
|
||||||
# Bright colors (pyte concatenates, Rich uses underscore)
|
|
||||||
"brightblack": "bright_black",
|
|
||||||
"brightred": "bright_red",
|
|
||||||
"brightgreen": "bright_green",
|
|
||||||
"brightbrown": "bright_yellow", # bright brown = bright yellow
|
|
||||||
"brightyellow": "bright_yellow",
|
|
||||||
"brightblue": "bright_blue",
|
|
||||||
"brightmagenta": "bright_magenta",
|
|
||||||
"bfightmagenta": "bright_magenta", # typo in pyte's BG_AIXTERM
|
|
||||||
"brightcyan": "bright_cyan",
|
|
||||||
"brightwhite": "bright_white",
|
|
||||||
# Standard colors
|
|
||||||
"brown": "yellow", # pyte uses 'brown' for ANSI color 33 (yellow)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _pyte_color_to_rich(color: str) -> str:
|
|
||||||
"""Convert pyte color to Rich-compatible color string.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- Named color mappings (e.g., 'brown' -> 'yellow')
|
|
||||||
- Bright color name format (e.g., 'brightred' -> 'bright_red')
|
|
||||||
- Hex colors from 256-color/truecolor (e.g., 'ff8700' -> '#ff8700')
|
|
||||||
"""
|
|
||||||
if color == "default":
|
|
||||||
return color
|
|
||||||
# Check mapping first
|
|
||||||
if color in PYTE_TO_RICH_COLOR:
|
|
||||||
return PYTE_TO_RICH_COLOR[color]
|
|
||||||
# If it looks like a hex color without #, add it
|
|
||||||
if len(color) == 6 and all(c in "0123456789abcdefABCDEF" for c in color):
|
|
||||||
return f"#{color}"
|
|
||||||
return color
|
|
||||||
|
|
||||||
|
|
||||||
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
|
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|
||||||
@@ -129,22 +81,6 @@ class LocalClientConnector(SessionConnector):
|
|||||||
await self.server.handle_session_close(self.session_id, self.route_key)
|
await self.server.handle_session_close(self.session_id, self.route_key)
|
||||||
|
|
||||||
|
|
||||||
def _rewrite_svg_fonts(svg: str) -> str:
|
|
||||||
"""Make Rich SVG output self-contained and aligned with our monospace styling."""
|
|
||||||
|
|
||||||
# Rich export_svg embeds @font-face rules that reference external CDNs.
|
|
||||||
svg = re.sub(r"@font-face\s*\{.*?\}\s*", "", svg, flags=re.DOTALL)
|
|
||||||
|
|
||||||
# Force our local monospace stack even if Rich sets font-family to Fira Code.
|
|
||||||
override = f"\ntext {{ font-family: {SVG_MONO_FONT_STACK} !important; }}\n"
|
|
||||||
if "</style>" in svg:
|
|
||||||
svg = svg.replace("</style>", override + "</style>", 1)
|
|
||||||
else:
|
|
||||||
svg = svg.replace("<svg ", f"<svg><style>{override}</style> ", 1)
|
|
||||||
|
|
||||||
return svg
|
|
||||||
|
|
||||||
|
|
||||||
class LocalServer:
|
class LocalServer:
|
||||||
def mark_route_activity(self, route_key: str) -> None:
|
def mark_route_activity(self, route_key: str) -> None:
|
||||||
now = asyncio.get_event_loop().time()
|
now = asyncio.get_event_loop().time()
|
||||||
@@ -575,68 +511,15 @@ class LocalServer:
|
|||||||
return cached_response
|
return cached_response
|
||||||
|
|
||||||
def _render_svg() -> str:
|
def _render_svg() -> str:
|
||||||
# Use the session's screen buffer directly - this has the correct
|
# Use custom SVG exporter - simpler and more reliable than Rich
|
||||||
# dimensions matching the actual terminal, preventing wrapping issues
|
return render_terminal_svg(
|
||||||
# Add extra height for Rich's clip path generation quirk
|
screen_buffer,
|
||||||
console = Console(
|
width=screen_width,
|
||||||
record=True, width=screen_width, height=screen_height + 2, file=io.StringIO()
|
height=screen_height,
|
||||||
)
|
|
||||||
|
|
||||||
for row_data in screen_buffer:
|
|
||||||
line = Text()
|
|
||||||
for char in row_data:
|
|
||||||
char_data = char["data"]
|
|
||||||
|
|
||||||
# Skip empty placeholder cells (after wide characters)
|
|
||||||
if not char_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Build Rich style from pyte character attributes
|
|
||||||
# Convert pyte color names to Rich-compatible format
|
|
||||||
style_kwargs = {}
|
|
||||||
if char["fg"] != "default":
|
|
||||||
style_kwargs["color"] = _pyte_color_to_rich(char["fg"])
|
|
||||||
if char["bg"] != "default":
|
|
||||||
style_kwargs["bgcolor"] = _pyte_color_to_rich(char["bg"])
|
|
||||||
if char["bold"]:
|
|
||||||
style_kwargs["bold"] = True
|
|
||||||
if char["italics"]:
|
|
||||||
style_kwargs["italic"] = True
|
|
||||||
if char["underscore"]:
|
|
||||||
style_kwargs["underline"] = True
|
|
||||||
if char["reverse"]:
|
|
||||||
style_kwargs["reverse"] = True
|
|
||||||
|
|
||||||
if style_kwargs:
|
|
||||||
line.append(char_data, Style(**style_kwargs))
|
|
||||||
else:
|
|
||||||
line.append(char_data)
|
|
||||||
|
|
||||||
console.print(line, highlight=False)
|
|
||||||
|
|
||||||
return console.export_svg(
|
|
||||||
title="textual-webterm",
|
title="textual-webterm",
|
||||||
code_format=(
|
|
||||||
'<svg class="rich-terminal" viewBox="0 0 {terminal_width} {terminal_height}" '
|
|
||||||
'xmlns="http://www.w3.org/2000/svg">'
|
|
||||||
'<style>{styles}</style>'
|
|
||||||
'<defs>'
|
|
||||||
'<clipPath id="{unique_id}-clip-terminal">'
|
|
||||||
'<rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" />'
|
|
||||||
'</clipPath>'
|
|
||||||
'{lines}'
|
|
||||||
'</defs>'
|
|
||||||
'<g clip-path="url(#{unique_id}-clip-terminal)">'
|
|
||||||
'<rect x="0" y="0" width="{terminal_width}" height="{terminal_height}" fill="#000" />'
|
|
||||||
'{backgrounds}'
|
|
||||||
'<g class="{unique_id}-matrix">{matrix}</g>'
|
|
||||||
'</g>'
|
|
||||||
'</svg>'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
svg = await asyncio.to_thread(_render_svg)
|
svg = await asyncio.to_thread(_render_svg)
|
||||||
svg = _rewrite_svg_fonts(svg)
|
|
||||||
etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest()
|
etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||||
self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg)
|
self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg)
|
||||||
self._screenshot_cache_etag[route_key] = etag
|
self._screenshot_cache_etag[route_key] = etag
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""Custom SVG exporter for terminal screenshots.
|
||||||
|
|
||||||
|
Generates SVG directly from pyte screen buffer, avoiding Rich's export_svg() quirks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
# ANSI color names to hex values (standard 16-color palette)
|
||||||
|
ANSI_COLORS: dict[str, str] = {
|
||||||
|
# Normal colors
|
||||||
|
"black": "#000000",
|
||||||
|
"red": "#cc0000",
|
||||||
|
"green": "#4e9a06",
|
||||||
|
"yellow": "#c4a000",
|
||||||
|
"blue": "#3465a4",
|
||||||
|
"magenta": "#75507b",
|
||||||
|
"cyan": "#06989a",
|
||||||
|
"white": "#d3d7cf",
|
||||||
|
# Bright colors
|
||||||
|
"brightblack": "#555753",
|
||||||
|
"brightred": "#ef2929",
|
||||||
|
"brightgreen": "#8ae234",
|
||||||
|
"brightyellow": "#fce94f",
|
||||||
|
"brightblue": "#729fcf",
|
||||||
|
"brightmagenta": "#ad7fa8",
|
||||||
|
"brightcyan": "#34e2e2",
|
||||||
|
"brightwhite": "#eeeeec",
|
||||||
|
# Alternative names
|
||||||
|
"gray": "#555753",
|
||||||
|
"grey": "#555753",
|
||||||
|
"lightgray": "#d3d7cf",
|
||||||
|
"lightgrey": "#d3d7cf",
|
||||||
|
"brown": "#c4a000",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default colors
|
||||||
|
DEFAULT_FG = "#d3d7cf"
|
||||||
|
DEFAULT_BG = "#000000"
|
||||||
|
|
||||||
|
# Font settings
|
||||||
|
FONT_FAMILY = (
|
||||||
|
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", '
|
||||||
|
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", '
|
||||||
|
'"DejaVu Sans Mono", "Courier New", monospace'
|
||||||
|
)
|
||||||
|
FONT_SIZE = 14
|
||||||
|
LINE_HEIGHT = 1.2
|
||||||
|
CHAR_WIDTH = 8.4 # Approximate width of monospace character at 14px
|
||||||
|
|
||||||
|
|
||||||
|
class CharData(TypedDict):
|
||||||
|
"""Character data from pyte screen buffer."""
|
||||||
|
|
||||||
|
data: str
|
||||||
|
fg: str
|
||||||
|
bg: str
|
||||||
|
bold: bool
|
||||||
|
italics: bool
|
||||||
|
underscore: bool
|
||||||
|
reverse: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _color_to_hex(color: str, is_foreground: bool = True) -> str:
|
||||||
|
"""Convert pyte color to hex value."""
|
||||||
|
if color == "default":
|
||||||
|
return DEFAULT_FG if is_foreground else DEFAULT_BG
|
||||||
|
|
||||||
|
# Already a hex color
|
||||||
|
if color.startswith("#"):
|
||||||
|
return color
|
||||||
|
|
||||||
|
# Named color lookup (case-insensitive)
|
||||||
|
lower = color.lower()
|
||||||
|
if lower in ANSI_COLORS:
|
||||||
|
return ANSI_COLORS[lower]
|
||||||
|
|
||||||
|
# RGB format "rgb(r,g,b)" - rarely used but handle it
|
||||||
|
if lower.startswith("rgb("):
|
||||||
|
# Not common in terminal output, return default
|
||||||
|
return DEFAULT_FG if is_foreground else DEFAULT_BG
|
||||||
|
|
||||||
|
return DEFAULT_FG if is_foreground else DEFAULT_BG
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_xml(text: str) -> str:
|
||||||
|
"""Escape special XML characters."""
|
||||||
|
return html.escape(text, quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def render_terminal_svg(
|
||||||
|
screen_buffer: list[list[CharData]],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
*,
|
||||||
|
title: str = "Terminal",
|
||||||
|
font_size: int = FONT_SIZE,
|
||||||
|
char_width: float = CHAR_WIDTH,
|
||||||
|
line_height: float = LINE_HEIGHT,
|
||||||
|
background: str = DEFAULT_BG,
|
||||||
|
foreground: str = DEFAULT_FG,
|
||||||
|
) -> str:
|
||||||
|
"""Render terminal screen buffer to SVG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
screen_buffer: 2D list of CharData dicts from pyte
|
||||||
|
width: Terminal width in columns
|
||||||
|
height: Terminal height in rows
|
||||||
|
title: SVG title (for accessibility)
|
||||||
|
font_size: Font size in pixels
|
||||||
|
char_width: Width of a single character
|
||||||
|
line_height: Line height multiplier
|
||||||
|
background: Background color
|
||||||
|
foreground: Default foreground color
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SVG string
|
||||||
|
"""
|
||||||
|
# Calculate dimensions
|
||||||
|
actual_line_height = font_size * line_height
|
||||||
|
svg_width = width * char_width + 20 # Add padding
|
||||||
|
svg_height = height * actual_line_height + 20
|
||||||
|
|
||||||
|
# Start building SVG
|
||||||
|
parts: list[str] = []
|
||||||
|
parts.append(
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||||
|
f'viewBox="0 0 {svg_width:.1f} {svg_height:.1f}" '
|
||||||
|
f'class="terminal-svg">'
|
||||||
|
)
|
||||||
|
parts.append(f"<title>{_escape_xml(title)}</title>")
|
||||||
|
|
||||||
|
# Style definitions
|
||||||
|
parts.append("<defs><style>")
|
||||||
|
parts.append(
|
||||||
|
f".terminal-bg {{ fill: {background}; }}"
|
||||||
|
f".terminal-text {{ "
|
||||||
|
f"font-family: {FONT_FAMILY}; "
|
||||||
|
f"font-size: {font_size}px; "
|
||||||
|
f"fill: {foreground}; "
|
||||||
|
f"white-space: pre; "
|
||||||
|
f"}}"
|
||||||
|
f".bold {{ font-weight: bold; }}"
|
||||||
|
f".italic {{ font-style: italic; }}"
|
||||||
|
f".underline {{ text-decoration: underline; }}"
|
||||||
|
)
|
||||||
|
parts.append("</style></defs>")
|
||||||
|
|
||||||
|
# Background rectangle
|
||||||
|
parts.append(
|
||||||
|
f'<rect class="terminal-bg" x="0" y="0" '
|
||||||
|
f'width="{svg_width:.1f}" height="{svg_height:.1f}"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Text content group
|
||||||
|
parts.append('<g class="terminal-text">')
|
||||||
|
|
||||||
|
# Render each row
|
||||||
|
for row_idx, row_data in enumerate(screen_buffer):
|
||||||
|
y = 10 + (row_idx + 1) * actual_line_height - (actual_line_height - font_size) / 2
|
||||||
|
|
||||||
|
# Build spans for this row, grouping consecutive chars with same style
|
||||||
|
spans = _build_row_spans(row_data, foreground, background)
|
||||||
|
|
||||||
|
if not spans:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Start text element for this row
|
||||||
|
parts.append(f'<text y="{y:.1f}">')
|
||||||
|
|
||||||
|
x = 10.0 # Starting x position with padding
|
||||||
|
for span in spans:
|
||||||
|
text = span["text"]
|
||||||
|
if not text or (text.isspace() and not span["has_bg"]):
|
||||||
|
# Skip empty spans without background, but advance position
|
||||||
|
x += len(text) * char_width
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build tspan attributes
|
||||||
|
attrs = [f'x="{x:.1f}"']
|
||||||
|
|
||||||
|
# Foreground color
|
||||||
|
if span["fg"] != foreground:
|
||||||
|
attrs.append(f'fill="{span["fg"]}"')
|
||||||
|
|
||||||
|
# Style classes
|
||||||
|
classes = []
|
||||||
|
if span["bold"]:
|
||||||
|
classes.append("bold")
|
||||||
|
if span["italic"]:
|
||||||
|
classes.append("italic")
|
||||||
|
if span["underline"]:
|
||||||
|
classes.append("underline")
|
||||||
|
if classes:
|
||||||
|
attrs.append(f'class="{" ".join(classes)}"')
|
||||||
|
|
||||||
|
# Background needs a separate rect
|
||||||
|
if span["has_bg"] and span["bg"] != background:
|
||||||
|
bg_width = len(text) * char_width
|
||||||
|
bg_y = y - font_size + 2
|
||||||
|
parts.insert(
|
||||||
|
-1, # Insert before current text element
|
||||||
|
f'<rect x="{x:.1f}" y="{bg_y:.1f}" '
|
||||||
|
f'width="{bg_width:.1f}" height="{actual_line_height:.1f}" '
|
||||||
|
f'fill="{span["bg"]}"/>',
|
||||||
|
)
|
||||||
|
|
||||||
|
parts.append(f'<tspan {" ".join(attrs)}>{_escape_xml(text)}</tspan>')
|
||||||
|
x += len(text) * char_width
|
||||||
|
|
||||||
|
parts.append("</text>")
|
||||||
|
|
||||||
|
parts.append("</g>")
|
||||||
|
parts.append("</svg>")
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class _Span(TypedDict):
|
||||||
|
"""A span of text with consistent styling."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
fg: str
|
||||||
|
bg: str
|
||||||
|
bold: bool
|
||||||
|
italic: bool
|
||||||
|
underline: bool
|
||||||
|
has_bg: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _build_row_spans(
|
||||||
|
row_data: list[CharData],
|
||||||
|
default_fg: str,
|
||||||
|
default_bg: str,
|
||||||
|
) -> list[_Span]:
|
||||||
|
"""Build styled spans from row data, merging consecutive chars with same style."""
|
||||||
|
if not row_data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
spans: list[_Span] = []
|
||||||
|
current_span: _Span | None = None
|
||||||
|
|
||||||
|
for char in row_data:
|
||||||
|
char_data = char["data"]
|
||||||
|
|
||||||
|
# Skip empty placeholder cells (after wide characters)
|
||||||
|
if not char_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get colors, handling reverse video
|
||||||
|
fg = _color_to_hex(char["fg"], is_foreground=True)
|
||||||
|
bg = _color_to_hex(char["bg"], is_foreground=False)
|
||||||
|
|
||||||
|
if char["reverse"]:
|
||||||
|
fg, bg = bg, fg
|
||||||
|
|
||||||
|
has_bg = bg != default_bg
|
||||||
|
|
||||||
|
# Check if we can extend current span
|
||||||
|
if (
|
||||||
|
current_span is not None
|
||||||
|
and current_span["fg"] == fg
|
||||||
|
and current_span["bg"] == bg
|
||||||
|
and current_span["bold"] == char["bold"]
|
||||||
|
and current_span["italic"] == char["italics"]
|
||||||
|
and current_span["underline"] == char["underscore"]
|
||||||
|
and current_span["has_bg"] == has_bg
|
||||||
|
):
|
||||||
|
current_span["text"] += char_data
|
||||||
|
else:
|
||||||
|
# Start new span
|
||||||
|
if current_span is not None:
|
||||||
|
spans.append(current_span)
|
||||||
|
current_span = {
|
||||||
|
"text": char_data,
|
||||||
|
"fg": fg,
|
||||||
|
"bg": bg,
|
||||||
|
"bold": char["bold"],
|
||||||
|
"italic": char["italics"],
|
||||||
|
"underline": char["underscore"],
|
||||||
|
"has_bg": has_bg,
|
||||||
|
}
|
||||||
|
|
||||||
|
if current_span is not None:
|
||||||
|
spans.append(current_span)
|
||||||
|
|
||||||
|
return spans
|
||||||
@@ -14,7 +14,6 @@ from collections import deque
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pyte
|
import pyte
|
||||||
import rich.repr
|
|
||||||
from importlib_metadata import version
|
from importlib_metadata import version
|
||||||
|
|
||||||
from .session import Session, SessionConnector
|
from .session import Session, SessionConnector
|
||||||
@@ -33,7 +32,6 @@ DEFAULT_SCREEN_WIDTH = 132
|
|||||||
DEFAULT_SCREEN_HEIGHT = 45
|
DEFAULT_SCREEN_HEIGHT = 45
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
|
||||||
class TerminalSession(Session):
|
class TerminalSession(Session):
|
||||||
"""A session that manages a terminal."""
|
"""A session that manages a terminal."""
|
||||||
|
|
||||||
@@ -61,9 +59,8 @@ class TerminalSession(Session):
|
|||||||
self._last_height = DEFAULT_SCREEN_HEIGHT
|
self._last_height = DEFAULT_SCREEN_HEIGHT
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
def __repr__(self) -> str:
|
||||||
yield "session_id", self.session_id
|
return f"TerminalSession(session_id={self.session_id!r}, command={self.command!r})"
|
||||||
yield "command", self.command
|
|
||||||
|
|
||||||
async def open(self, width: int = 80, height: int = 24) -> None:
|
async def open(self, width: int = 80, height: int = 24) -> None:
|
||||||
log.info("Opening terminal session %s with command: %s", self.session_id, self.command)
|
log.info("Opening terminal session %s with command: %s", self.session_id, self.command)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from textual_webterm.config import App, Config
|
|||||||
from textual_webterm.local_server import (
|
from textual_webterm.local_server import (
|
||||||
LocalClientConnector,
|
LocalClientConnector,
|
||||||
LocalServer,
|
LocalServer,
|
||||||
_rewrite_svg_fonts,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -238,8 +237,7 @@ class TestLocalServerHelpers:
|
|||||||
response = await server._handle_screenshot(request)
|
response = await server._handle_screenshot(request)
|
||||||
assert response.content_type == "image/svg+xml"
|
assert response.content_type == "image/svg+xml"
|
||||||
assert "<svg" in response.text
|
assert "<svg" in response.text
|
||||||
assert "ui-monospace" in response.text
|
assert "ui-monospace" in response.text # Custom exporter uses ui-monospace font
|
||||||
assert "cdnjs.cloudflare.com" not in response.text
|
|
||||||
assert created["called"][0] == "known"
|
assert created["called"][0] == "known"
|
||||||
assert created["called"][1:] == (132, 45)
|
assert created["called"][1:] == (132, 45)
|
||||||
|
|
||||||
@@ -490,23 +488,6 @@ class TestLocalServerMoreCoverage:
|
|||||||
assert "data-font-size" in resp.text
|
assert "data-font-size" in resp.text
|
||||||
assert "<title>Known</title>" in resp.text
|
assert "<title>Known</title>" in resp.text
|
||||||
|
|
||||||
def test_rewrite_svg_fonts_removes_font_face_and_forces_stack(self):
|
|
||||||
svg = (
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
|
||||||
'<style>@font-face{src:url(https://cdnjs.cloudflare.com/x);} text{font-family:Fira Code;}</style>'
|
|
||||||
'<text>hi</text>'
|
|
||||||
'</svg>'
|
|
||||||
)
|
|
||||||
out = _rewrite_svg_fonts(svg)
|
|
||||||
assert "@font-face" not in out
|
|
||||||
assert "cdnjs.cloudflare.com" not in out
|
|
||||||
assert "ui-monospace" in out
|
|
||||||
|
|
||||||
def test_rewrite_svg_fonts_injects_style_if_missing(self):
|
|
||||||
svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hi</text></svg>'
|
|
||||||
out = _rewrite_svg_fonts(svg)
|
|
||||||
assert "ui-monospace" in out
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cached_screenshot_etag_returns_304(self, server_with_no_apps):
|
async def test_cached_screenshot_etag_returns_304(self, server_with_no_apps):
|
||||||
request = MagicMock()
|
request = MagicMock()
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
"""Extensive tests for the custom SVG exporter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual_webterm.svg_exporter import (
|
||||||
|
ANSI_COLORS,
|
||||||
|
DEFAULT_BG,
|
||||||
|
DEFAULT_FG,
|
||||||
|
CharData,
|
||||||
|
_build_row_spans,
|
||||||
|
_color_to_hex,
|
||||||
|
_escape_xml,
|
||||||
|
render_terminal_svg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestColorToHex:
|
||||||
|
"""Tests for _color_to_hex function."""
|
||||||
|
|
||||||
|
def test_default_foreground(self) -> None:
|
||||||
|
"""Default color returns DEFAULT_FG for foreground."""
|
||||||
|
assert _color_to_hex("default", is_foreground=True) == DEFAULT_FG
|
||||||
|
|
||||||
|
def test_default_background(self) -> None:
|
||||||
|
"""Default color returns DEFAULT_BG for background."""
|
||||||
|
assert _color_to_hex("default", is_foreground=False) == DEFAULT_BG
|
||||||
|
|
||||||
|
def test_hex_color_passthrough(self) -> None:
|
||||||
|
"""Hex colors pass through unchanged."""
|
||||||
|
assert _color_to_hex("#ff0000") == "#ff0000"
|
||||||
|
assert _color_to_hex("#123456") == "#123456"
|
||||||
|
assert _color_to_hex("#AABBCC") == "#AABBCC"
|
||||||
|
|
||||||
|
def test_named_colors(self) -> None:
|
||||||
|
"""Named ANSI colors map correctly."""
|
||||||
|
assert _color_to_hex("red") == ANSI_COLORS["red"]
|
||||||
|
assert _color_to_hex("green") == ANSI_COLORS["green"]
|
||||||
|
assert _color_to_hex("blue") == ANSI_COLORS["blue"]
|
||||||
|
assert _color_to_hex("white") == ANSI_COLORS["white"]
|
||||||
|
assert _color_to_hex("black") == ANSI_COLORS["black"]
|
||||||
|
|
||||||
|
def test_bright_colors(self) -> None:
|
||||||
|
"""Bright color variants map correctly."""
|
||||||
|
assert _color_to_hex("brightred") == ANSI_COLORS["brightred"]
|
||||||
|
assert _color_to_hex("brightgreen") == ANSI_COLORS["brightgreen"]
|
||||||
|
assert _color_to_hex("brightblue") == ANSI_COLORS["brightblue"]
|
||||||
|
|
||||||
|
def test_case_insensitive(self) -> None:
|
||||||
|
"""Color names are case-insensitive."""
|
||||||
|
assert _color_to_hex("RED") == ANSI_COLORS["red"]
|
||||||
|
assert _color_to_hex("Green") == ANSI_COLORS["green"]
|
||||||
|
assert _color_to_hex("BRIGHTBLUE") == ANSI_COLORS["brightblue"]
|
||||||
|
|
||||||
|
def test_unknown_color_returns_default(self) -> None:
|
||||||
|
"""Unknown color names return default."""
|
||||||
|
assert _color_to_hex("unknowncolor", is_foreground=True) == DEFAULT_FG
|
||||||
|
assert _color_to_hex("unknowncolor", is_foreground=False) == DEFAULT_BG
|
||||||
|
|
||||||
|
def test_gray_aliases(self) -> None:
|
||||||
|
"""Gray/grey aliases work."""
|
||||||
|
assert _color_to_hex("gray") == ANSI_COLORS["gray"]
|
||||||
|
assert _color_to_hex("grey") == ANSI_COLORS["grey"]
|
||||||
|
assert _color_to_hex("lightgray") == ANSI_COLORS["lightgray"]
|
||||||
|
assert _color_to_hex("lightgrey") == ANSI_COLORS["lightgrey"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscapeXml:
|
||||||
|
"""Tests for XML escaping."""
|
||||||
|
|
||||||
|
def test_no_special_chars(self) -> None:
|
||||||
|
"""Plain text passes through unchanged."""
|
||||||
|
assert _escape_xml("hello world") == "hello world"
|
||||||
|
|
||||||
|
def test_less_than(self) -> None:
|
||||||
|
"""Less than is escaped."""
|
||||||
|
assert _escape_xml("<") == "<"
|
||||||
|
assert _escape_xml("a < b") == "a < b"
|
||||||
|
|
||||||
|
def test_greater_than(self) -> None:
|
||||||
|
"""Greater than is escaped."""
|
||||||
|
assert _escape_xml(">") == ">"
|
||||||
|
assert _escape_xml("a > b") == "a > b"
|
||||||
|
|
||||||
|
def test_ampersand(self) -> None:
|
||||||
|
"""Ampersand is escaped."""
|
||||||
|
assert _escape_xml("&") == "&"
|
||||||
|
assert _escape_xml("a & b") == "a & b"
|
||||||
|
|
||||||
|
def test_quotes(self) -> None:
|
||||||
|
"""Quotes are escaped."""
|
||||||
|
assert _escape_xml('"') == """
|
||||||
|
assert _escape_xml("'") == "'"
|
||||||
|
|
||||||
|
def test_mixed_special_chars(self) -> None:
|
||||||
|
"""Multiple special chars are all escaped."""
|
||||||
|
assert _escape_xml('<script>"alert"</script>') == (
|
||||||
|
"<script>"alert"</script>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unicode_preserved(self) -> None:
|
||||||
|
"""Unicode characters are preserved."""
|
||||||
|
assert _escape_xml("你好世界") == "你好世界"
|
||||||
|
assert _escape_xml("🎉🚀") == "🎉🚀"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildRowSpans:
|
||||||
|
"""Tests for _build_row_spans function."""
|
||||||
|
|
||||||
|
def _char(
|
||||||
|
self,
|
||||||
|
data: str,
|
||||||
|
fg: str = "default",
|
||||||
|
bg: str = "default",
|
||||||
|
bold: bool = False,
|
||||||
|
italics: bool = False,
|
||||||
|
underscore: bool = False,
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> CharData:
|
||||||
|
"""Helper to create CharData."""
|
||||||
|
return {
|
||||||
|
"data": data,
|
||||||
|
"fg": fg,
|
||||||
|
"bg": bg,
|
||||||
|
"bold": bold,
|
||||||
|
"italics": italics,
|
||||||
|
"underscore": underscore,
|
||||||
|
"reverse": reverse,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_empty_row(self) -> None:
|
||||||
|
"""Empty row returns no spans."""
|
||||||
|
assert _build_row_spans([], DEFAULT_FG, DEFAULT_BG) == []
|
||||||
|
|
||||||
|
def test_single_char(self) -> None:
|
||||||
|
"""Single character produces one span."""
|
||||||
|
row = [self._char("A")]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 1
|
||||||
|
assert spans[0]["text"] == "A"
|
||||||
|
|
||||||
|
def test_consecutive_same_style_merged(self) -> None:
|
||||||
|
"""Consecutive chars with same style are merged."""
|
||||||
|
row = [self._char("H"), self._char("e"), self._char("l"), self._char("l"), self._char("o")]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 1
|
||||||
|
assert spans[0]["text"] == "Hello"
|
||||||
|
|
||||||
|
def test_different_colors_split(self) -> None:
|
||||||
|
"""Different colors create separate spans."""
|
||||||
|
row = [
|
||||||
|
self._char("R", fg="red"),
|
||||||
|
self._char("G", fg="green"),
|
||||||
|
self._char("B", fg="blue"),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 3
|
||||||
|
assert spans[0]["text"] == "R"
|
||||||
|
assert spans[0]["fg"] == ANSI_COLORS["red"]
|
||||||
|
assert spans[1]["text"] == "G"
|
||||||
|
assert spans[1]["fg"] == ANSI_COLORS["green"]
|
||||||
|
assert spans[2]["text"] == "B"
|
||||||
|
assert spans[2]["fg"] == ANSI_COLORS["blue"]
|
||||||
|
|
||||||
|
def test_same_color_merged(self) -> None:
|
||||||
|
"""Same color chars are merged."""
|
||||||
|
row = [
|
||||||
|
self._char("A", fg="red"),
|
||||||
|
self._char("B", fg="red"),
|
||||||
|
self._char("C", fg="red"),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 1
|
||||||
|
assert spans[0]["text"] == "ABC"
|
||||||
|
assert spans[0]["fg"] == ANSI_COLORS["red"]
|
||||||
|
|
||||||
|
def test_bold_creates_new_span(self) -> None:
|
||||||
|
"""Bold attribute creates new span."""
|
||||||
|
row = [
|
||||||
|
self._char("N"),
|
||||||
|
self._char("B", bold=True),
|
||||||
|
self._char("N"),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 3
|
||||||
|
assert spans[0]["bold"] is False
|
||||||
|
assert spans[1]["bold"] is True
|
||||||
|
assert spans[1]["text"] == "B"
|
||||||
|
assert spans[2]["bold"] is False
|
||||||
|
|
||||||
|
def test_italic_creates_new_span(self) -> None:
|
||||||
|
"""Italic attribute creates new span."""
|
||||||
|
row = [
|
||||||
|
self._char("N"),
|
||||||
|
self._char("I", italics=True),
|
||||||
|
self._char("N"),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 3
|
||||||
|
assert spans[1]["italic"] is True
|
||||||
|
|
||||||
|
def test_underline_creates_new_span(self) -> None:
|
||||||
|
"""Underline attribute creates new span."""
|
||||||
|
row = [
|
||||||
|
self._char("N"),
|
||||||
|
self._char("U", underscore=True),
|
||||||
|
self._char("N"),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 3
|
||||||
|
assert spans[1]["underline"] is True
|
||||||
|
|
||||||
|
def test_reverse_swaps_colors(self) -> None:
|
||||||
|
"""Reverse video swaps foreground and background."""
|
||||||
|
row = [self._char("R", fg="red", bg="blue", reverse=True)]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 1
|
||||||
|
# Colors should be swapped
|
||||||
|
assert spans[0]["fg"] == ANSI_COLORS["blue"]
|
||||||
|
assert spans[0]["bg"] == ANSI_COLORS["red"]
|
||||||
|
|
||||||
|
def test_background_color_tracked(self) -> None:
|
||||||
|
"""Background color is tracked in has_bg flag."""
|
||||||
|
row = [
|
||||||
|
self._char("N"),
|
||||||
|
self._char("B", bg="red"),
|
||||||
|
self._char("N"),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert spans[0]["has_bg"] is False
|
||||||
|
assert spans[1]["has_bg"] is True
|
||||||
|
assert spans[2]["has_bg"] is False
|
||||||
|
|
||||||
|
def test_wide_char_placeholder_skipped(self) -> None:
|
||||||
|
"""Empty placeholder cells (after wide chars) are skipped."""
|
||||||
|
row = [
|
||||||
|
self._char("A"),
|
||||||
|
self._char("中"), # Wide char
|
||||||
|
self._char(""), # Placeholder - should be skipped
|
||||||
|
self._char("B"),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
# Should merge into single span since all default style
|
||||||
|
assert len(spans) == 1
|
||||||
|
assert spans[0]["text"] == "A中B"
|
||||||
|
|
||||||
|
def test_multiple_wide_chars(self) -> None:
|
||||||
|
"""Multiple wide characters handled correctly."""
|
||||||
|
row = [
|
||||||
|
self._char("日"),
|
||||||
|
self._char(""),
|
||||||
|
self._char("本"),
|
||||||
|
self._char(""),
|
||||||
|
self._char("語"),
|
||||||
|
self._char(""),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 1
|
||||||
|
assert spans[0]["text"] == "日本語"
|
||||||
|
|
||||||
|
def test_emoji_with_placeholder(self) -> None:
|
||||||
|
"""Emoji characters with placeholders handled."""
|
||||||
|
row = [
|
||||||
|
self._char("🎉"),
|
||||||
|
self._char(""),
|
||||||
|
self._char(" "),
|
||||||
|
self._char("🚀"),
|
||||||
|
self._char(""),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 1
|
||||||
|
assert spans[0]["text"] == "🎉 🚀"
|
||||||
|
|
||||||
|
def test_mixed_styles_complex(self) -> None:
|
||||||
|
"""Complex mix of styles produces correct spans."""
|
||||||
|
row = [
|
||||||
|
self._char("H", fg="red", bold=True),
|
||||||
|
self._char("e", fg="red", bold=True),
|
||||||
|
self._char("l", fg="green"),
|
||||||
|
self._char("l", fg="green"),
|
||||||
|
self._char("o", fg="blue", italics=True),
|
||||||
|
]
|
||||||
|
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
|
||||||
|
assert len(spans) == 3
|
||||||
|
assert spans[0]["text"] == "He"
|
||||||
|
assert spans[0]["bold"] is True
|
||||||
|
assert spans[1]["text"] == "ll"
|
||||||
|
assert spans[1]["bold"] is False
|
||||||
|
assert spans[2]["text"] == "o"
|
||||||
|
assert spans[2]["italic"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderTerminalSvg:
|
||||||
|
"""Tests for render_terminal_svg function."""
|
||||||
|
|
||||||
|
def _char(
|
||||||
|
self,
|
||||||
|
data: str,
|
||||||
|
fg: str = "default",
|
||||||
|
bg: str = "default",
|
||||||
|
bold: bool = False,
|
||||||
|
italics: bool = False,
|
||||||
|
underscore: bool = False,
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> CharData:
|
||||||
|
"""Helper to create CharData."""
|
||||||
|
return {
|
||||||
|
"data": data,
|
||||||
|
"fg": fg,
|
||||||
|
"bg": bg,
|
||||||
|
"bold": bold,
|
||||||
|
"italics": italics,
|
||||||
|
"underscore": underscore,
|
||||||
|
"reverse": reverse,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _make_buffer(self, rows: list[str]) -> list[list[CharData]]:
|
||||||
|
"""Create simple buffer from strings."""
|
||||||
|
return [[self._char(c) for c in row] for row in rows]
|
||||||
|
|
||||||
|
def test_empty_buffer(self) -> None:
|
||||||
|
"""Empty buffer produces valid SVG."""
|
||||||
|
svg = render_terminal_svg([], width=80, height=24)
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
assert svg.endswith("</svg>")
|
||||||
|
assert 'xmlns="http://www.w3.org/2000/svg"' in svg
|
||||||
|
|
||||||
|
def test_basic_text_output(self) -> None:
|
||||||
|
"""Basic text is included in SVG."""
|
||||||
|
buffer = self._make_buffer(["Hello, World!"])
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert "Hello, World!" in svg
|
||||||
|
|
||||||
|
def test_multiline_output(self) -> None:
|
||||||
|
"""Multiple lines render correctly."""
|
||||||
|
buffer = self._make_buffer(["Line 1", "Line 2", "Line 3"])
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert "Line 1" in svg
|
||||||
|
assert "Line 2" in svg
|
||||||
|
assert "Line 3" in svg
|
||||||
|
# Should have 3 text elements
|
||||||
|
assert svg.count("<text y=") == 3
|
||||||
|
|
||||||
|
def test_special_chars_escaped(self) -> None:
|
||||||
|
"""Special XML characters are properly escaped."""
|
||||||
|
buffer = self._make_buffer(["<script>&test</script>"])
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert "<script>" in svg
|
||||||
|
assert "&test" in svg
|
||||||
|
assert "<script>" not in svg # Should not appear unescaped
|
||||||
|
|
||||||
|
def test_colored_text(self) -> None:
|
||||||
|
"""Colored text gets fill attribute."""
|
||||||
|
buffer = [[self._char("R", fg="red"), self._char("G", fg="green")]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert f'fill="{ANSI_COLORS["red"]}"' in svg
|
||||||
|
assert f'fill="{ANSI_COLORS["green"]}"' in svg
|
||||||
|
|
||||||
|
def test_bold_text(self) -> None:
|
||||||
|
"""Bold text gets bold class."""
|
||||||
|
buffer = [[self._char("B", bold=True)]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert 'class="bold"' in svg
|
||||||
|
|
||||||
|
def test_italic_text(self) -> None:
|
||||||
|
"""Italic text gets italic class."""
|
||||||
|
buffer = [[self._char("I", italics=True)]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert 'class="italic"' in svg
|
||||||
|
|
||||||
|
def test_underline_text(self) -> None:
|
||||||
|
"""Underlined text gets underline class."""
|
||||||
|
buffer = [[self._char("U", underscore=True)]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert 'class="underline"' in svg
|
||||||
|
|
||||||
|
def test_combined_styles(self) -> None:
|
||||||
|
"""Multiple styles can be combined."""
|
||||||
|
buffer = [[self._char("X", bold=True, italics=True, underscore=True)]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
# Should have all three classes
|
||||||
|
assert "bold" in svg
|
||||||
|
assert "italic" in svg
|
||||||
|
assert "underline" in svg
|
||||||
|
|
||||||
|
def test_background_color(self) -> None:
|
||||||
|
"""Background color creates rect element."""
|
||||||
|
buffer = [[self._char("X", bg="red")]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert f'fill="{ANSI_COLORS["red"]}"' in svg
|
||||||
|
# Should have a rect for background
|
||||||
|
assert "<rect" in svg
|
||||||
|
|
||||||
|
def test_unicode_text(self) -> None:
|
||||||
|
"""Unicode text is preserved."""
|
||||||
|
buffer = self._make_buffer(["你好世界"])
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert "你好世界" in svg
|
||||||
|
|
||||||
|
def test_emoji_text(self) -> None:
|
||||||
|
"""Emoji are preserved."""
|
||||||
|
buffer = self._make_buffer(["🎉🚀✨"])
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert "🎉🚀✨" in svg
|
||||||
|
|
||||||
|
def test_wide_char_with_placeholder(self) -> None:
|
||||||
|
"""Wide chars with placeholders render correctly."""
|
||||||
|
buffer = [
|
||||||
|
[
|
||||||
|
self._char("A"),
|
||||||
|
self._char("中"),
|
||||||
|
self._char(""), # Placeholder
|
||||||
|
self._char("B"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert "A中B" in svg
|
||||||
|
|
||||||
|
def test_viewbox_dimensions(self) -> None:
|
||||||
|
"""ViewBox matches calculated dimensions."""
|
||||||
|
svg = render_terminal_svg([], width=80, height=24)
|
||||||
|
assert 'viewBox="0 0' in svg
|
||||||
|
|
||||||
|
def test_title_included(self) -> None:
|
||||||
|
"""Title is included in SVG."""
|
||||||
|
svg = render_terminal_svg([], width=80, height=24, title="My Terminal")
|
||||||
|
assert "<title>My Terminal</title>" in svg
|
||||||
|
|
||||||
|
def test_title_escaped(self) -> None:
|
||||||
|
"""Title with special chars is escaped."""
|
||||||
|
svg = render_terminal_svg([], width=80, height=24, title="<Test>")
|
||||||
|
assert "<title><Test></title>" in svg
|
||||||
|
|
||||||
|
def test_custom_font_size(self) -> None:
|
||||||
|
"""Custom font size is applied."""
|
||||||
|
svg = render_terminal_svg([], width=80, height=24, font_size=16)
|
||||||
|
assert "font-size: 16px" in svg
|
||||||
|
|
||||||
|
def test_custom_background(self) -> None:
|
||||||
|
"""Custom background color is applied."""
|
||||||
|
svg = render_terminal_svg([], width=80, height=24, background="#1a1a1a")
|
||||||
|
assert 'fill: #1a1a1a' in svg
|
||||||
|
|
||||||
|
def test_style_definitions_present(self) -> None:
|
||||||
|
"""CSS style definitions are included."""
|
||||||
|
svg = render_terminal_svg([], width=80, height=24)
|
||||||
|
assert "<style>" in svg
|
||||||
|
assert ".terminal-bg" in svg
|
||||||
|
assert ".terminal-text" in svg
|
||||||
|
assert ".bold" in svg
|
||||||
|
assert ".italic" in svg
|
||||||
|
assert ".underline" in svg
|
||||||
|
|
||||||
|
def test_full_screen_render(self) -> None:
|
||||||
|
"""Full terminal screen renders without error."""
|
||||||
|
# Create a 80x24 screen with various content
|
||||||
|
buffer: list[list[CharData]] = []
|
||||||
|
for row in range(24):
|
||||||
|
row_data: list[CharData] = []
|
||||||
|
for col in range(80):
|
||||||
|
char = chr(32 + ((row * 80 + col) % 95)) # Printable ASCII
|
||||||
|
row_data.append(self._char(char))
|
||||||
|
buffer.append(row_data)
|
||||||
|
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
assert svg.endswith("</svg>")
|
||||||
|
# Should have 24 text elements
|
||||||
|
assert svg.count("<text y=") == 24
|
||||||
|
|
||||||
|
def test_reverse_video_rendering(self) -> None:
|
||||||
|
"""Reverse video swaps colors correctly."""
|
||||||
|
buffer = [[self._char("X", fg="white", bg="black", reverse=True)]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
# Colors should be swapped, so fg should be black's color
|
||||||
|
assert ANSI_COLORS["black"] in svg
|
||||||
|
|
||||||
|
def test_hex_color_passthrough(self) -> None:
|
||||||
|
"""Hex colors in buffer pass through to SVG."""
|
||||||
|
buffer = [[self._char("X", fg="#ff5733")]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
assert 'fill="#ff5733"' in svg
|
||||||
|
|
||||||
|
def test_whitespace_handling(self) -> None:
|
||||||
|
"""Whitespace is preserved."""
|
||||||
|
buffer = self._make_buffer([" indented text "])
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
# white-space: pre should be in styles
|
||||||
|
assert "white-space: pre" in svg
|
||||||
|
|
||||||
|
|
||||||
|
class TestSvgStructure:
|
||||||
|
"""Tests for SVG document structure."""
|
||||||
|
|
||||||
|
def test_valid_xml_structure(self) -> None:
|
||||||
|
"""SVG has valid XML structure."""
|
||||||
|
svg = render_terminal_svg([], width=80, height=24)
|
||||||
|
# Basic structure checks
|
||||||
|
assert svg.count("<svg") == 1
|
||||||
|
assert svg.count("</svg>") == 1
|
||||||
|
assert svg.count("<defs>") == 1
|
||||||
|
assert svg.count("</defs>") == 1
|
||||||
|
assert svg.count("<style>") == 1
|
||||||
|
assert svg.count("</style>") == 1
|
||||||
|
|
||||||
|
def test_all_tags_closed(self) -> None:
|
||||||
|
"""All opened tags are properly closed."""
|
||||||
|
buffer = [[{"data": "X", "fg": "red", "bg": "blue", "bold": True,
|
||||||
|
"italics": False, "underscore": False, "reverse": False}]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||||
|
|
||||||
|
# Count opening and closing tags
|
||||||
|
assert svg.count("<g") == svg.count("</g>")
|
||||||
|
assert svg.count("<text") == svg.count("</text>")
|
||||||
|
|
||||||
|
def test_namespace_declared(self) -> None:
|
||||||
|
"""SVG namespace is properly declared."""
|
||||||
|
svg = render_terminal_svg([], width=80, height=24)
|
||||||
|
assert 'xmlns="http://www.w3.org/2000/svg"' in svg
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Tests for edge cases and boundary conditions."""
|
||||||
|
|
||||||
|
def _char(
|
||||||
|
self,
|
||||||
|
data: str,
|
||||||
|
fg: str = "default",
|
||||||
|
bg: str = "default",
|
||||||
|
bold: bool = False,
|
||||||
|
italics: bool = False,
|
||||||
|
underscore: bool = False,
|
||||||
|
reverse: bool = False,
|
||||||
|
) -> CharData:
|
||||||
|
"""Helper to create CharData."""
|
||||||
|
return {
|
||||||
|
"data": data,
|
||||||
|
"fg": fg,
|
||||||
|
"bg": bg,
|
||||||
|
"bold": bold,
|
||||||
|
"italics": italics,
|
||||||
|
"underscore": underscore,
|
||||||
|
"reverse": reverse,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_single_cell(self) -> None:
|
||||||
|
"""Single cell terminal renders."""
|
||||||
|
buffer = [[self._char("X")]]
|
||||||
|
svg = render_terminal_svg(buffer, width=1, height=1)
|
||||||
|
assert "X" in svg
|
||||||
|
|
||||||
|
def test_very_wide_terminal(self) -> None:
|
||||||
|
"""Very wide terminal (200 cols) renders."""
|
||||||
|
row = [self._char("X") for _ in range(200)]
|
||||||
|
svg = render_terminal_svg([row], width=200, height=1)
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
|
||||||
|
def test_very_tall_terminal(self) -> None:
|
||||||
|
"""Very tall terminal (100 rows) renders."""
|
||||||
|
buffer = [[self._char("X")] for _ in range(100)]
|
||||||
|
svg = render_terminal_svg(buffer, width=1, height=100)
|
||||||
|
assert svg.count("<text y=") == 100
|
||||||
|
|
||||||
|
def test_all_spaces(self) -> None:
|
||||||
|
"""Row of all spaces renders."""
|
||||||
|
buffer = [[self._char(" ") for _ in range(80)]]
|
||||||
|
svg = render_terminal_svg(buffer, width=80, height=1)
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
|
||||||
|
def test_null_chars_as_space(self) -> None:
|
||||||
|
"""Null characters (empty string) are skipped."""
|
||||||
|
buffer = [[self._char(""), self._char("A"), self._char("")]]
|
||||||
|
svg = render_terminal_svg(buffer, width=3, height=1)
|
||||||
|
assert "A" in svg
|
||||||
|
|
||||||
|
def test_mixed_width_characters(self) -> None:
|
||||||
|
"""Mix of narrow and wide characters."""
|
||||||
|
buffer = [
|
||||||
|
[
|
||||||
|
self._char("A"),
|
||||||
|
self._char("中"),
|
||||||
|
self._char(""),
|
||||||
|
self._char("B"),
|
||||||
|
self._char("🎉"),
|
||||||
|
self._char(""),
|
||||||
|
self._char("C"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
svg = render_terminal_svg(buffer, width=7, height=1)
|
||||||
|
assert "A中B🎉C" in svg
|
||||||
|
|
||||||
|
def test_special_unicode_blocks(self) -> None:
|
||||||
|
"""Unicode box drawing characters render."""
|
||||||
|
buffer = [[
|
||||||
|
self._char("┌"),
|
||||||
|
self._char("─"),
|
||||||
|
self._char("┐"),
|
||||||
|
]]
|
||||||
|
svg = render_terminal_svg(buffer, width=3, height=1)
|
||||||
|
assert "┌─┐" in svg
|
||||||
|
|
||||||
|
def test_ansi_bright_colors(self) -> None:
|
||||||
|
"""All bright ANSI colors render."""
|
||||||
|
colors = ["brightred", "brightgreen", "brightyellow",
|
||||||
|
"brightblue", "brightmagenta", "brightcyan"]
|
||||||
|
buffer = [[self._char("X", fg=c) for c in colors]]
|
||||||
|
svg = render_terminal_svg(buffer, width=len(colors), height=1)
|
||||||
|
for color in colors:
|
||||||
|
assert ANSI_COLORS[color] in svg
|
||||||
|
|
||||||
|
def test_rapid_color_changes(self) -> None:
|
||||||
|
"""Rapid color changes (each char different) render."""
|
||||||
|
colors = ["red", "green", "blue", "yellow", "magenta", "cyan"]
|
||||||
|
buffer = [[self._char(str(i), fg=colors[i % len(colors)]) for i in range(20)]]
|
||||||
|
svg = render_terminal_svg(buffer, width=20, height=1)
|
||||||
|
# Should have multiple tspan elements
|
||||||
|
assert svg.count("<tspan") >= 1
|
||||||
|
|
||||||
|
def test_all_attributes_at_once(self) -> None:
|
||||||
|
"""Character with all attributes renders."""
|
||||||
|
buffer = [[self._char("X", fg="red", bg="blue", bold=True,
|
||||||
|
italics=True, underscore=True, reverse=True)]]
|
||||||
|
svg = render_terminal_svg(buffer, width=1, height=1)
|
||||||
|
assert "bold" in svg
|
||||||
|
assert "italic" in svg
|
||||||
|
assert "underline" in svg
|
||||||
@@ -220,16 +220,16 @@ class TestTerminalSession:
|
|||||||
# Should not raise
|
# Should not raise
|
||||||
await session.wait()
|
await session.wait()
|
||||||
|
|
||||||
def test_rich_repr(self):
|
def test_repr(self):
|
||||||
"""Test rich repr output."""
|
"""Test repr output."""
|
||||||
from textual_webterm.terminal_session import TerminalSession
|
from textual_webterm.terminal_session import TerminalSession
|
||||||
|
|
||||||
mock_poller = MagicMock()
|
mock_poller = MagicMock()
|
||||||
session = TerminalSession(mock_poller, "test-session", "bash")
|
session = TerminalSession(mock_poller, "test-session", "bash")
|
||||||
|
|
||||||
repr_items = list(session.__rich_repr__())
|
repr_str = repr(session)
|
||||||
assert ("session_id", "test-session") in repr_items
|
assert "test-session" in repr_str
|
||||||
assert ("command", "bash") in repr_items
|
assert "bash" in repr_str
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_uses_shlex_split_and_execvp_with_args(self):
|
async def test_open_uses_shlex_split_and_execvp_with_args(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user