From 7b215beb2a9cd96647bf8e77e5d0092332a2bdfb Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Thu, 29 Jan 2026 22:40:27 +0000 Subject: [PATCH] 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. --- src/webterm/local_server.py | 262 ++++++++++++++++++++++++++++++++ src/webterm/svg_exporter.py | 49 +++++- tests/test_local_server_unit.py | 9 ++ tests/test_svg_exporter.py | 24 +++ 4 files changed, 336 insertions(+), 8 deletions(-) diff --git a/src/webterm/local_server.py b/src/webterm/local_server.py index 73fd817..a47956b 100644 --- a/src/webterm/local_server.py +++ b/src/webterm/local_server.py @@ -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) diff --git a/src/webterm/svg_exporter.py b/src/webterm/svg_exporter.py index d80eebb..2dda53c 100644 --- a/src/webterm/svg_exporter.py +++ b/src/webterm/svg_exporter.py @@ -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 diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py index ff3b545..d21ae6c 100644 --- a/tests/test_local_server_unit.py +++ b/tests/test_local_server_unit.py @@ -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 " 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."""