feat: render screenshots using session theme palettes
Pass per-theme background, foreground, and 16-color palettes into the SVG exporter so screenshots match the active session theme (including ANSI colors). Adds theme palette mappings and updates screenshot tests to validate themed backgrounds and palette-aware color conversion.
This commit is contained in:
@@ -50,6 +50,7 @@ WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
|
||||
|
||||
# Theme background colors - must match terminal.ts THEMES
|
||||
THEME_BACKGROUNDS: dict[str, str] = {
|
||||
"tango": "#000000",
|
||||
"xterm": "#000000",
|
||||
"monokai": "#2d2a2e",
|
||||
"ristretto": "#2d2525",
|
||||
@@ -63,6 +64,250 @@ THEME_BACKGROUNDS: dict[str, str] = {
|
||||
"tokyo": "#1a1b26",
|
||||
}
|
||||
|
||||
# Theme palettes - must match terminal.ts THEMES
|
||||
THEME_PALETTES: dict[str, dict[str, str]] = {
|
||||
"tango": {
|
||||
"background": "#000000",
|
||||
"foreground": "#d3d7cf",
|
||||
"black": "#2e3436",
|
||||
"red": "#cc0000",
|
||||
"green": "#4e9a06",
|
||||
"yellow": "#c4a000",
|
||||
"blue": "#3465a4",
|
||||
"magenta": "#75507b",
|
||||
"cyan": "#06989a",
|
||||
"white": "#d3d7cf",
|
||||
"brightblack": "#555753",
|
||||
"brightred": "#ef2929",
|
||||
"brightgreen": "#8ae234",
|
||||
"brightyellow": "#fce94f",
|
||||
"brightblue": "#729fcf",
|
||||
"brightmagenta": "#ad7fa8",
|
||||
"brightcyan": "#34e2e2",
|
||||
"brightwhite": "#eeeeec",
|
||||
},
|
||||
"xterm": {
|
||||
"background": "#000000",
|
||||
"foreground": "#e5e5e5",
|
||||
"black": "#000000",
|
||||
"red": "#cd0000",
|
||||
"green": "#00cd00",
|
||||
"yellow": "#cdcd00",
|
||||
"blue": "#0000cd",
|
||||
"magenta": "#cd00cd",
|
||||
"cyan": "#00cdcd",
|
||||
"white": "#e5e5e5",
|
||||
"brightblack": "#4d4d4d",
|
||||
"brightred": "#ff0000",
|
||||
"brightgreen": "#00ff00",
|
||||
"brightyellow": "#ffff00",
|
||||
"brightblue": "#0000ff",
|
||||
"brightmagenta": "#ff00ff",
|
||||
"brightcyan": "#00ffff",
|
||||
"brightwhite": "#ffffff",
|
||||
},
|
||||
"monokai": {
|
||||
"background": "#2d2a2e",
|
||||
"foreground": "#fcfcfa",
|
||||
"black": "#403e41",
|
||||
"red": "#ff6188",
|
||||
"green": "#a9dc76",
|
||||
"yellow": "#ffd866",
|
||||
"blue": "#fc9867",
|
||||
"magenta": "#ab9df2",
|
||||
"cyan": "#78dce8",
|
||||
"white": "#fcfcfa",
|
||||
"brightblack": "#727072",
|
||||
"brightred": "#ff6188",
|
||||
"brightgreen": "#a9dc76",
|
||||
"brightyellow": "#ffd866",
|
||||
"brightblue": "#fc9867",
|
||||
"brightmagenta": "#ab9df2",
|
||||
"brightcyan": "#78dce8",
|
||||
"brightwhite": "#fcfcfa",
|
||||
},
|
||||
"ristretto": {
|
||||
"background": "#2d2525",
|
||||
"foreground": "#fff1f3",
|
||||
"black": "#2c2525",
|
||||
"red": "#fd6883",
|
||||
"green": "#adda78",
|
||||
"yellow": "#f9cc6c",
|
||||
"blue": "#f38d70",
|
||||
"magenta": "#a8a9eb",
|
||||
"cyan": "#85dacc",
|
||||
"white": "#f9f8f5",
|
||||
"brightblack": "#655761",
|
||||
"brightred": "#fd6883",
|
||||
"brightgreen": "#adda78",
|
||||
"brightyellow": "#f9cc6c",
|
||||
"brightblue": "#f38d70",
|
||||
"brightmagenta": "#a8a9eb",
|
||||
"brightcyan": "#85dacc",
|
||||
"brightwhite": "#f9f8f5",
|
||||
},
|
||||
"dark": {
|
||||
"background": "#1e1e1e",
|
||||
"foreground": "#d4d4d4",
|
||||
"black": "#000000",
|
||||
"red": "#cd3131",
|
||||
"green": "#0dbc79",
|
||||
"yellow": "#e5e510",
|
||||
"blue": "#2472c8",
|
||||
"magenta": "#bc3fbc",
|
||||
"cyan": "#11a8cd",
|
||||
"white": "#e5e5e5",
|
||||
"brightblack": "#666666",
|
||||
"brightred": "#f14c4c",
|
||||
"brightgreen": "#23d18b",
|
||||
"brightyellow": "#f5f543",
|
||||
"brightblue": "#3b8eea",
|
||||
"brightmagenta": "#d670d6",
|
||||
"brightcyan": "#29b8db",
|
||||
"brightwhite": "#ffffff",
|
||||
},
|
||||
"light": {
|
||||
"background": "#ffffff",
|
||||
"foreground": "#383a42",
|
||||
"black": "#000000",
|
||||
"red": "#e45649",
|
||||
"green": "#50a14f",
|
||||
"yellow": "#c18401",
|
||||
"blue": "#4078f2",
|
||||
"magenta": "#a626a4",
|
||||
"cyan": "#0184bc",
|
||||
"white": "#a0a1a7",
|
||||
"brightblack": "#5c6370",
|
||||
"brightred": "#e06c75",
|
||||
"brightgreen": "#98c379",
|
||||
"brightyellow": "#d19a66",
|
||||
"brightblue": "#61afef",
|
||||
"brightmagenta": "#c678dd",
|
||||
"brightcyan": "#56b6c2",
|
||||
"brightwhite": "#ffffff",
|
||||
},
|
||||
"dracula": {
|
||||
"background": "#282a36",
|
||||
"foreground": "#f8f8f2",
|
||||
"black": "#21222c",
|
||||
"red": "#ff5555",
|
||||
"green": "#50fa7b",
|
||||
"yellow": "#f1fa8c",
|
||||
"blue": "#bd93f9",
|
||||
"magenta": "#ff79c6",
|
||||
"cyan": "#8be9fd",
|
||||
"white": "#f8f8f2",
|
||||
"brightblack": "#6272a4",
|
||||
"brightred": "#ff6e6e",
|
||||
"brightgreen": "#69ff94",
|
||||
"brightyellow": "#ffffa5",
|
||||
"brightblue": "#d6acff",
|
||||
"brightmagenta": "#ff92df",
|
||||
"brightcyan": "#a4ffff",
|
||||
"brightwhite": "#ffffff",
|
||||
},
|
||||
"catppuccin": {
|
||||
"background": "#1e1e2e",
|
||||
"foreground": "#cdd6f4",
|
||||
"black": "#45475a",
|
||||
"red": "#f38ba8",
|
||||
"green": "#a6e3a1",
|
||||
"yellow": "#f9e2af",
|
||||
"blue": "#89b4fa",
|
||||
"magenta": "#f5c2e7",
|
||||
"cyan": "#94e2d5",
|
||||
"white": "#bac2de",
|
||||
"brightblack": "#585b70",
|
||||
"brightred": "#f38ba8",
|
||||
"brightgreen": "#a6e3a1",
|
||||
"brightyellow": "#f9e2af",
|
||||
"brightblue": "#89b4fa",
|
||||
"brightmagenta": "#f5c2e7",
|
||||
"brightcyan": "#94e2d5",
|
||||
"brightwhite": "#a6adc8",
|
||||
},
|
||||
"nord": {
|
||||
"background": "#2e3440",
|
||||
"foreground": "#d8dee9",
|
||||
"black": "#3b4252",
|
||||
"red": "#bf616a",
|
||||
"green": "#a3be8c",
|
||||
"yellow": "#ebcb8b",
|
||||
"blue": "#81a1c1",
|
||||
"magenta": "#b48ead",
|
||||
"cyan": "#88c0d0",
|
||||
"white": "#e5e9f0",
|
||||
"brightblack": "#4c566a",
|
||||
"brightred": "#bf616a",
|
||||
"brightgreen": "#a3be8c",
|
||||
"brightyellow": "#ebcb8b",
|
||||
"brightblue": "#81a1c1",
|
||||
"brightmagenta": "#b48ead",
|
||||
"brightcyan": "#8fbcbb",
|
||||
"brightwhite": "#eceff4",
|
||||
},
|
||||
"gruvbox": {
|
||||
"background": "#282828",
|
||||
"foreground": "#ebdbb2",
|
||||
"black": "#282828",
|
||||
"red": "#cc241d",
|
||||
"green": "#98971a",
|
||||
"yellow": "#d79921",
|
||||
"blue": "#458588",
|
||||
"magenta": "#b16286",
|
||||
"cyan": "#689d6a",
|
||||
"white": "#a89984",
|
||||
"brightblack": "#928374",
|
||||
"brightred": "#fb4934",
|
||||
"brightgreen": "#b8bb26",
|
||||
"brightyellow": "#fabd2f",
|
||||
"brightblue": "#83a598",
|
||||
"brightmagenta": "#d3869b",
|
||||
"brightcyan": "#8ec07c",
|
||||
"brightwhite": "#ebdbb2",
|
||||
},
|
||||
"solarized": {
|
||||
"background": "#002b36",
|
||||
"foreground": "#839496",
|
||||
"black": "#073642",
|
||||
"red": "#dc322f",
|
||||
"green": "#859900",
|
||||
"yellow": "#b58900",
|
||||
"blue": "#268bd2",
|
||||
"magenta": "#d33682",
|
||||
"cyan": "#2aa198",
|
||||
"white": "#eee8d5",
|
||||
"brightblack": "#586e75",
|
||||
"brightred": "#cb4b16",
|
||||
"brightgreen": "#586e75",
|
||||
"brightyellow": "#657b83",
|
||||
"brightblue": "#839496",
|
||||
"brightmagenta": "#6c71c4",
|
||||
"brightcyan": "#93a1a1",
|
||||
"brightwhite": "#fdf6e3",
|
||||
},
|
||||
"tokyo": {
|
||||
"background": "#1a1b26",
|
||||
"foreground": "#a9b1d6",
|
||||
"black": "#15161e",
|
||||
"red": "#f7768e",
|
||||
"green": "#9ece6a",
|
||||
"yellow": "#e0af68",
|
||||
"blue": "#7aa2f7",
|
||||
"magenta": "#bb9af7",
|
||||
"cyan": "#7dcfff",
|
||||
"white": "#a9b1d6",
|
||||
"brightblack": "#414868",
|
||||
"brightred": "#f7768e",
|
||||
"brightgreen": "#9ece6a",
|
||||
"brightyellow": "#e0af68",
|
||||
"brightblue": "#7aa2f7",
|
||||
"brightmagenta": "#bb9af7",
|
||||
"brightcyan": "#7dcfff",
|
||||
"brightwhite": "#c0caf5",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LocalClientConnector(SessionConnector):
|
||||
"""Local connector that handles communication between sessions and local server."""
|
||||
@@ -611,6 +856,20 @@ class LocalServer:
|
||||
if cached_response is not None:
|
||||
return cached_response
|
||||
|
||||
theme_name = None
|
||||
app = self.session_manager.apps_by_slug.get(route_key)
|
||||
if app is not None and app.theme:
|
||||
theme_name = app.theme.lower()
|
||||
else:
|
||||
theme_name = self.theme.lower()
|
||||
|
||||
palette = THEME_PALETTES.get(theme_name)
|
||||
if palette is None:
|
||||
palette = THEME_PALETTES.get("xterm")
|
||||
|
||||
background = palette.get("background", THEME_BACKGROUNDS.get("xterm", "#000000"))
|
||||
foreground = palette.get("foreground", "#e5e5e5")
|
||||
|
||||
def _render_svg() -> str:
|
||||
# Use custom SVG exporter - simpler and more reliable than Rich
|
||||
return render_terminal_svg(
|
||||
@@ -618,6 +877,9 @@ class LocalServer:
|
||||
width=screen_width,
|
||||
height=screen_height,
|
||||
title="webterm",
|
||||
background=background,
|
||||
foreground=foreground,
|
||||
palette=palette,
|
||||
)
|
||||
|
||||
svg = await asyncio.to_thread(_render_svg)
|
||||
|
||||
@@ -82,10 +82,27 @@ class CharData(TypedDict):
|
||||
reverse: bool
|
||||
|
||||
|
||||
def _color_to_hex(color: str, is_foreground: bool = True) -> str:
|
||||
def _normalize_palette(palette: dict[str, str]) -> dict[str, str]:
|
||||
normalized = {key.lower(): value for key, value in palette.items()}
|
||||
normalized.setdefault("gray", normalized.get("brightblack", ANSI_COLORS["gray"]))
|
||||
normalized.setdefault("grey", normalized.get("brightblack", ANSI_COLORS["grey"]))
|
||||
normalized.setdefault("lightgray", normalized.get("white", ANSI_COLORS["lightgray"]))
|
||||
normalized.setdefault("lightgrey", normalized.get("white", ANSI_COLORS["lightgrey"]))
|
||||
normalized.setdefault("brown", normalized.get("yellow", ANSI_COLORS["brown"]))
|
||||
return normalized
|
||||
|
||||
|
||||
def _color_to_hex(
|
||||
color: str,
|
||||
is_foreground: bool = True,
|
||||
*,
|
||||
palette: dict[str, str] | None = None,
|
||||
default_fg: str = DEFAULT_FG,
|
||||
default_bg: str = DEFAULT_BG,
|
||||
) -> str:
|
||||
"""Convert pyte color to hex value."""
|
||||
if color == "default":
|
||||
return DEFAULT_FG if is_foreground else DEFAULT_BG
|
||||
return default_fg if is_foreground else default_bg
|
||||
|
||||
# Already a hex color with #
|
||||
if color.startswith("#"):
|
||||
@@ -98,15 +115,16 @@ def _color_to_hex(color: str, is_foreground: bool = True) -> str:
|
||||
|
||||
# Named color lookup (case-insensitive)
|
||||
lower = color.lower()
|
||||
if lower in ANSI_COLORS:
|
||||
return ANSI_COLORS[lower]
|
||||
palette_map = palette if palette is not None else ANSI_COLORS
|
||||
if lower in palette_map:
|
||||
return palette_map[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
|
||||
|
||||
return DEFAULT_FG if is_foreground else DEFAULT_BG
|
||||
return default_fg if is_foreground else default_bg
|
||||
|
||||
|
||||
def _escape_xml(text: str) -> str:
|
||||
@@ -125,6 +143,7 @@ def render_terminal_svg(
|
||||
line_height: float = LINE_HEIGHT,
|
||||
background: str = DEFAULT_BG,
|
||||
foreground: str = DEFAULT_FG,
|
||||
palette: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Render terminal screen buffer to SVG.
|
||||
|
||||
@@ -186,6 +205,8 @@ def render_terminal_svg(
|
||||
|
||||
# Render each row - use explicit x position for EACH character
|
||||
# to ensure pixel-perfect alignment regardless of font metrics
|
||||
palette_map = _normalize_palette(palette) if palette is not None else ANSI_COLORS
|
||||
|
||||
for row_idx, row_data in enumerate(screen_buffer):
|
||||
# rect_y is the top of the cell
|
||||
rect_y = 10 + row_idx * actual_line_height
|
||||
@@ -214,8 +235,20 @@ def render_terminal_svg(
|
||||
x = 10.0 + col * char_width
|
||||
|
||||
# Get colors, handling reverse video
|
||||
fg = _color_to_hex(char["fg"], is_foreground=True)
|
||||
bg = _color_to_hex(char["bg"], is_foreground=False)
|
||||
fg = _color_to_hex(
|
||||
char["fg"],
|
||||
is_foreground=True,
|
||||
palette=palette_map,
|
||||
default_fg=foreground,
|
||||
default_bg=background,
|
||||
)
|
||||
bg = _color_to_hex(
|
||||
char["bg"],
|
||||
is_foreground=False,
|
||||
palette=palette_map,
|
||||
default_fg=foreground,
|
||||
default_bg=background,
|
||||
)
|
||||
if char["reverse"]:
|
||||
fg, bg = bg, fg
|
||||
|
||||
|
||||
@@ -183,6 +183,14 @@ class TestLocalServerHelpers:
|
||||
|
||||
screen_buffer = screen_buffer_factory(["hello", ""])
|
||||
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
||||
server.session_manager.apps_by_slug["rk"] = App(
|
||||
name="Test",
|
||||
slug="rk",
|
||||
path="./",
|
||||
command="echo test",
|
||||
terminal=True,
|
||||
theme="dracula",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
server.session_manager, "get_session_by_route_key", lambda _rk: mock_session
|
||||
@@ -191,6 +199,7 @@ class TestLocalServerHelpers:
|
||||
response = await server._handle_screenshot(request)
|
||||
assert response.content_type == "image/svg+xml"
|
||||
assert "<svg" in response.text
|
||||
assert "#282a36" in response.text
|
||||
|
||||
out = capsys.readouterr()
|
||||
assert out.out == ""
|
||||
|
||||
@@ -55,6 +55,30 @@ class TestColorToHex:
|
||||
"""Color conversion covers named/hex/default cases."""
|
||||
assert _color_to_hex(color, is_foreground=is_foreground) == expected
|
||||
|
||||
def test_color_to_hex_uses_palette_defaults(self) -> None:
|
||||
palette = {"red": "#123456"}
|
||||
assert _color_to_hex(
|
||||
"default",
|
||||
is_foreground=True,
|
||||
palette=palette,
|
||||
default_fg="#111111",
|
||||
default_bg="#222222",
|
||||
) == "#111111"
|
||||
assert _color_to_hex(
|
||||
"default",
|
||||
is_foreground=False,
|
||||
palette=palette,
|
||||
default_fg="#111111",
|
||||
default_bg="#222222",
|
||||
) == "#222222"
|
||||
assert _color_to_hex(
|
||||
"red",
|
||||
is_foreground=True,
|
||||
palette=palette,
|
||||
default_fg="#111111",
|
||||
default_bg="#222222",
|
||||
) == "#123456"
|
||||
|
||||
|
||||
class TestEscapeXml:
|
||||
"""Tests for XML escaping."""
|
||||
|
||||
Reference in New Issue
Block a user