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:
GitHub Copilot
2026-01-24 17:11:20 +00:00
parent d4acdbb4f1
commit d5a060d6aa
9 changed files with 934 additions and 165 deletions
+1 -1
View File
@@ -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"
+3 -7
View File
@@ -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."""
+2 -4
View File
@@ -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")
+6 -123
View File
@@ -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
+289
View File
@@ -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
+2 -5
View File
@@ -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)
+1 -20
View File
@@ -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()
+625
View File
@@ -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("<") == "&lt;"
assert _escape_xml("a < b") == "a &lt; b"
def test_greater_than(self) -> None:
"""Greater than is escaped."""
assert _escape_xml(">") == "&gt;"
assert _escape_xml("a > b") == "a &gt; b"
def test_ampersand(self) -> None:
"""Ampersand is escaped."""
assert _escape_xml("&") == "&amp;"
assert _escape_xml("a & b") == "a &amp; b"
def test_quotes(self) -> None:
"""Quotes are escaped."""
assert _escape_xml('"') == "&quot;"
assert _escape_xml("'") == "&#x27;"
def test_mixed_special_chars(self) -> None:
"""Multiple special chars are all escaped."""
assert _escape_xml('<script>"alert"</script>') == (
"&lt;script&gt;&quot;alert&quot;&lt;/script&gt;"
)
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 "&lt;script&gt;" in svg
assert "&amp;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>&lt;Test&gt;</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
+5 -5
View File
@@ -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):