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:
GitHub Copilot
2026-01-29 22:40:27 +00:00
parent 3b3246bbd0
commit 7b215beb2a
4 changed files with 336 additions and 8 deletions
+262
View File
@@ -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)
+41 -8
View File
@@ -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
+9
View File
@@ -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 == ""
+24
View File
@@ -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."""