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 background colors - must match terminal.ts THEMES
|
||||||
THEME_BACKGROUNDS: dict[str, str] = {
|
THEME_BACKGROUNDS: dict[str, str] = {
|
||||||
|
"tango": "#000000",
|
||||||
"xterm": "#000000",
|
"xterm": "#000000",
|
||||||
"monokai": "#2d2a2e",
|
"monokai": "#2d2a2e",
|
||||||
"ristretto": "#2d2525",
|
"ristretto": "#2d2525",
|
||||||
@@ -63,6 +64,250 @@ THEME_BACKGROUNDS: dict[str, str] = {
|
|||||||
"tokyo": "#1a1b26",
|
"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):
|
class LocalClientConnector(SessionConnector):
|
||||||
"""Local connector that handles communication between sessions and local server."""
|
"""Local connector that handles communication between sessions and local server."""
|
||||||
@@ -611,6 +856,20 @@ class LocalServer:
|
|||||||
if cached_response is not None:
|
if cached_response is not None:
|
||||||
return cached_response
|
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:
|
def _render_svg() -> str:
|
||||||
# Use custom SVG exporter - simpler and more reliable than Rich
|
# Use custom SVG exporter - simpler and more reliable than Rich
|
||||||
return render_terminal_svg(
|
return render_terminal_svg(
|
||||||
@@ -618,6 +877,9 @@ class LocalServer:
|
|||||||
width=screen_width,
|
width=screen_width,
|
||||||
height=screen_height,
|
height=screen_height,
|
||||||
title="webterm",
|
title="webterm",
|
||||||
|
background=background,
|
||||||
|
foreground=foreground,
|
||||||
|
palette=palette,
|
||||||
)
|
)
|
||||||
|
|
||||||
svg = await asyncio.to_thread(_render_svg)
|
svg = await asyncio.to_thread(_render_svg)
|
||||||
|
|||||||
@@ -82,10 +82,27 @@ class CharData(TypedDict):
|
|||||||
reverse: bool
|
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."""
|
"""Convert pyte color to hex value."""
|
||||||
if color == "default":
|
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 #
|
# Already a hex color with #
|
||||||
if color.startswith("#"):
|
if color.startswith("#"):
|
||||||
@@ -98,15 +115,16 @@ def _color_to_hex(color: str, is_foreground: bool = True) -> str:
|
|||||||
|
|
||||||
# Named color lookup (case-insensitive)
|
# Named color lookup (case-insensitive)
|
||||||
lower = color.lower()
|
lower = color.lower()
|
||||||
if lower in ANSI_COLORS:
|
palette_map = palette if palette is not None else ANSI_COLORS
|
||||||
return ANSI_COLORS[lower]
|
if lower in palette_map:
|
||||||
|
return palette_map[lower]
|
||||||
|
|
||||||
# RGB format "rgb(r,g,b)" - rarely used but handle it
|
# RGB format "rgb(r,g,b)" - rarely used but handle it
|
||||||
if lower.startswith("rgb("):
|
if lower.startswith("rgb("):
|
||||||
# Not common in terminal output, return default
|
# 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:
|
def _escape_xml(text: str) -> str:
|
||||||
@@ -125,6 +143,7 @@ def render_terminal_svg(
|
|||||||
line_height: float = LINE_HEIGHT,
|
line_height: float = LINE_HEIGHT,
|
||||||
background: str = DEFAULT_BG,
|
background: str = DEFAULT_BG,
|
||||||
foreground: str = DEFAULT_FG,
|
foreground: str = DEFAULT_FG,
|
||||||
|
palette: dict[str, str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render terminal screen buffer to SVG.
|
"""Render terminal screen buffer to SVG.
|
||||||
|
|
||||||
@@ -186,6 +205,8 @@ def render_terminal_svg(
|
|||||||
|
|
||||||
# Render each row - use explicit x position for EACH character
|
# Render each row - use explicit x position for EACH character
|
||||||
# to ensure pixel-perfect alignment regardless of font metrics
|
# 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):
|
for row_idx, row_data in enumerate(screen_buffer):
|
||||||
# rect_y is the top of the cell
|
# rect_y is the top of the cell
|
||||||
rect_y = 10 + row_idx * actual_line_height
|
rect_y = 10 + row_idx * actual_line_height
|
||||||
@@ -214,8 +235,20 @@ def render_terminal_svg(
|
|||||||
x = 10.0 + col * char_width
|
x = 10.0 + col * char_width
|
||||||
|
|
||||||
# Get colors, handling reverse video
|
# Get colors, handling reverse video
|
||||||
fg = _color_to_hex(char["fg"], is_foreground=True)
|
fg = _color_to_hex(
|
||||||
bg = _color_to_hex(char["bg"], is_foreground=False)
|
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"]:
|
if char["reverse"]:
|
||||||
fg, bg = bg, fg
|
fg, bg = bg, fg
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,14 @@ class TestLocalServerHelpers:
|
|||||||
|
|
||||||
screen_buffer = screen_buffer_factory(["hello", ""])
|
screen_buffer = screen_buffer_factory(["hello", ""])
|
||||||
mock_session.get_screen_snapshot = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
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(
|
monkeypatch.setattr(
|
||||||
server.session_manager, "get_session_by_route_key", lambda _rk: mock_session
|
server.session_manager, "get_session_by_route_key", lambda _rk: mock_session
|
||||||
@@ -191,6 +199,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 "#282a36" in response.text
|
||||||
|
|
||||||
out = capsys.readouterr()
|
out = capsys.readouterr()
|
||||||
assert out.out == ""
|
assert out.out == ""
|
||||||
|
|||||||
@@ -55,6 +55,30 @@ class TestColorToHex:
|
|||||||
"""Color conversion covers named/hex/default cases."""
|
"""Color conversion covers named/hex/default cases."""
|
||||||
assert _color_to_hex(color, is_foreground=is_foreground) == expected
|
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:
|
class TestEscapeXml:
|
||||||
"""Tests for XML escaping."""
|
"""Tests for XML escaping."""
|
||||||
|
|||||||
Reference in New Issue
Block a user