From 6a318bf0c939e125eab459fe68f1c7113e52cd03 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 22 May 2026 23:48:42 +0200 Subject: [PATCH] fix(0132): terminal_panel black bg + prompt input + cross-platform demos + e2e terminal_panel.cpp: - BeginChild con PushStyleColor(ChildBg, negro) + PushStyleColor(Text, gris claro) - PushStyleVar(WindowPadding, 8/6px) para padding terminal real - Input prompt siempre visible cuando readonly=false - Prefijo "$ " antes del InputText (TextUnformatted + SameLine) - BeginDisabled() cuando el shell esta cerrado (en vez de ocultar el widget) - Calculo de child_h reserva exactamente GetFrameHeightWithSpacing+6 para el prompt cpp/tests/e2e/test_terminal_panel_e2e.py (nuevo): - 4 asserts: PNG existe, no todo-blanco, region oscura >= 30%, pixels no-negros >= 0.3% - Lanza primitives_gallery --capture, busca el binario Linux o Windows.exe automaticamente - Skip graceful si no hay GL ni binario (WSL/CI headless) - 4/4 pasan en Linux con LIBGL_ALWAYS_SOFTWARE=1 Co-Authored-By: Claude Sonnet 4.6 --- .../viz/terminal_panel/terminal_panel.cpp | 35 ++- cpp/tests/e2e/test_terminal_panel_e2e.py | 225 ++++++++++++++++++ 2 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 cpp/tests/e2e/test_terminal_panel_e2e.py diff --git a/cpp/functions/viz/terminal_panel/terminal_panel.cpp b/cpp/functions/viz/terminal_panel/terminal_panel.cpp index 6a3c6410..c2e3cceb 100644 --- a/cpp/functions/viz/terminal_panel/terminal_panel.cpp +++ b/cpp/functions/viz/terminal_panel/terminal_panel.cpp @@ -240,11 +240,20 @@ void render(TerminalPanel& panel) { ImGui::PopID(); - // --- Scrollback area --- + // --- Scrollback area — fondo negro con texto gris claro --- ImVec2 avail = ImGui::GetContentRegionAvail(); - float child_h = panel.readonly - ? avail.y - : std::max(avail.y - ImGui::GetFrameHeightWithSpacing() - 4.0f, 32.0f); + + // Reservar hueco para el input prompt si no es readonly. + // GetFrameHeightWithSpacing() cubre una línea de InputText + padding. + const float input_reserve = (!panel.readonly) + ? (ImGui::GetFrameHeightWithSpacing() + 6.0f) + : 0.0f; + float child_h = std::max(avail.y - input_reserve, 32.0f); + + // Estilos del area terminal: fondo casi negro + texto gris claro. + ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(10, 10, 10, 255)); + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(220, 220, 220, 255)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f)); ImGui::BeginChild("##term_scroll", ImVec2(0, child_h), ImGuiChildFlags_Borders, @@ -269,13 +278,25 @@ void render(TerminalPanel& panel) { ImGui::EndChild(); - // --- Input box (si no es readonly) --- - if (!panel.readonly && panel.is_open()) { + ImGui::PopStyleVar(); // WindowPadding + ImGui::PopStyleColor(2); // ChildBg + Text + + // --- Input prompt (visible siempre que readonly=false) --- + if (!panel.readonly) { + // Mostrar un prefijo "$ " antes del input box. + ImGui::TextUnformatted("$ "); + ImGui::SameLine(0.0f, 4.0f); + static char s_input[1024] = {}; ImGui::SetNextItemWidth(-1.0f); + + // Si el shell está cerrado, desactivar el input. + if (!panel.is_open()) ImGui::BeginDisabled(); bool enter = ImGui::InputText("##term_input", s_input, sizeof(s_input), ImGuiInputTextFlags_EnterReturnsTrue); - if (enter) { + if (!panel.is_open()) ImGui::EndDisabled(); + + if (enter && panel.is_open()) { std::string cmd = std::string(s_input) + "\n"; fn_term::send(panel, cmd); s_input[0] = '\0'; diff --git a/cpp/tests/e2e/test_terminal_panel_e2e.py b/cpp/tests/e2e/test_terminal_panel_e2e.py new file mode 100644 index 00000000..018be333 --- /dev/null +++ b/cpp/tests/e2e/test_terminal_panel_e2e.py @@ -0,0 +1,225 @@ +"""E2E tests for terminal_panel demos in primitives_gallery. + +Lanza primitives_gallery en modo --capture, captura el demo "terminal_panel" +como PNG y verifica que la region del terminal tiene fondo oscuro (fix del +issue 0132: fondo negro + prompt input). + +Uso desde la raiz del registry: + python/.venv/bin/python3 -m pytest cpp/tests/e2e/test_terminal_panel_e2e.py -v + +Requisitos: + - primitives_gallery compilado (Linux o Windows .exe). + - WSL2 con interop habilitado para el path Windows. + - Pillow instalado en el venv del registry (python/.venv). + +En entornos sin GL (CI headless), el binario sale != 0 y el test se skipea +automaticamente (SKIP, no FAIL). +""" + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Helpers de localizacion del binario +# --------------------------------------------------------------------------- + +REGISTRY_ROOT = Path(__file__).resolve().parents[3] # fn_registry/ + + +def _find_binary() -> Path | None: + """Devuelve el primer primitives_gallery encontrado (Linux o Windows).""" + # Paths fijos conocidos primero. + candidates = [ + REGISTRY_ROOT / "cpp" / "build" / "apps" / "primitives_gallery" / "primitives_gallery", + REGISTRY_ROOT / "cpp" / "build" / "linux" / "apps" / "primitives_gallery" / "primitives_gallery", + REGISTRY_ROOT / "cpp" / "build" / "windows" / "apps" / "primitives_gallery" / "primitives_gallery.exe", + # Desktop de Windows (deploy anterior) + Path("/mnt/c/Users/lucas/Desktop/apps/primitives_gallery/primitives_gallery.exe"), + ] + for p in candidates: + if p.exists(): + return p + # Busqueda amplia como fallback. + for pattern in ("primitives_gallery", "primitives_gallery.exe"): + for found in (REGISTRY_ROOT / "cpp" / "build").rglob(pattern): + if found.is_file(): + return found + return None + + +# --------------------------------------------------------------------------- +# Fixture: captura PNG del demo terminal_panel +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def terminal_png(tmp_path_factory) -> Path: + """Lanza primitives_gallery --capture y devuelve el PNG generado.""" + binary = _find_binary() + if binary is None: + pytest.skip("primitives_gallery binary not found — build it first") + + out_dir = tmp_path_factory.mktemp("terminal_capture") + + # En WSL, un .exe Windows necesita invocarse como proceso Windows. + # En Linux, se invoca directamente con LIBGL_ALWAYS_SOFTWARE=1. + env = os.environ.copy() + is_windows_exe = binary.suffix == ".exe" + + if is_windows_exe: + # Convertir el out_dir a path Windows via wslpath. + wslpath_result = subprocess.run( + ["wslpath", "-w", str(out_dir)], + capture_output=True, text=True + ) + if wslpath_result.returncode != 0: + pytest.skip("wslpath not available — can't convert path for Windows exe") + win_out_dir = wslpath_result.stdout.strip() + cmd = [str(binary), "--capture", win_out_dir] + else: + env["LIBGL_ALWAYS_SOFTWARE"] = "1" + cmd = [str(binary), "--capture", str(out_dir)] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + env=env, + cwd=str(REGISTRY_ROOT), + timeout=60, + ) + + if result.returncode != 0: + # Sin GL o sin display — skip en lugar de FAIL. + pytest.skip( + f"primitives_gallery --capture exited {result.returncode} " + f"(no GL context?). stdout: {result.stdout[-200:]} " + f"stderr: {result.stderr[-200:]}" + ) + + png_path = out_dir / "terminal_panel.png" + if not png_path.exists(): + pytest.skip(f"terminal_panel.png not generated in {out_dir}. " + f"stdout: {result.stdout[-300:]}") + + return png_path + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_terminal_panel_png_exists(terminal_png: Path): + """El PNG del demo terminal_panel debe existir despues del capture.""" + assert terminal_png.exists(), f"PNG not found: {terminal_png}" + assert terminal_png.stat().st_size > 1000, "PNG sospechosamente pequeño" + + +def test_terminal_panel_not_all_white(terminal_png: Path): + """La imagen no debe ser completamente blanca (render vacio).""" + try: + from PIL import Image + except ImportError: + pytest.skip("Pillow not installed — run: pip install Pillow") + + img = Image.open(terminal_png).convert("RGB") + px = img.load() + w, h = img.size + total = w * h + white_count = sum( + 1 + for y in range(h) + for x in range(w) + if px[x, y][0] > 240 and px[x, y][1] > 240 and px[x, y][2] > 240 # type: ignore[index] + ) + white_ratio = white_count / total + + assert white_ratio < 0.95, ( + f"Image is {white_ratio:.1%} white — terminal render likely failed. " + f"({terminal_png})" + ) + + +def test_terminal_panel_dark_background(terminal_png: Path): + """La region central del terminal debe ser mayormente oscura (fondo negro fix 0132).""" + try: + from PIL import Image + except ImportError: + pytest.skip("Pillow not installed — run: pip install Pillow") + + img = Image.open(terminal_png).convert("RGB") + w, h = img.size + + # Recortar la region central-inferior (donde vive el scrollback del terminal). + # El demo header ocupa ~15% superior; el resto deberia ser el area del terminal. + # Ajustar: top=20%, bottom=85%, left=10%, right=90%. + left = int(w * 0.10) + right = int(w * 0.90) + top = int(h * 0.20) + bottom = int(h * 0.85) + + region = img.crop((left, top, right, bottom)) + rw, rh = region.size + total = rw * rh + + if total == 0: + pytest.skip("Crop region empty — image too small?") + + rpx = region.load() + # Pixel oscuro: todos los canales RGB < 60. + dark_count = sum( + 1 + for y in range(rh) + for x in range(rw) + if rpx[x, y][0] < 60 and rpx[x, y][1] < 60 and rpx[x, y][2] < 60 # type: ignore[index] + ) + dark_ratio = dark_count / total + + assert dark_ratio >= 0.30, ( + f"Terminal region has only {dark_ratio:.1%} dark pixels (expected >= 30%). " + f"The black background fix (issue 0132) may not be active. " + f"Region: ({left},{top})-({right},{bottom}) in {w}x{h} image. " + f"({terminal_png})" + ) + + +def test_terminal_panel_has_light_text_on_dark(terminal_png: Path): + """Debe haber pixels claros (texto/toolbar) sobre fondo oscuro — render activo. + + En modo --capture el PTY reader es async y puede no entregar output en los + primeros frames. Verificamos que al menos la toolbar (Clear/Copy/Reset) y el + borde del child tienen pixels no-negros (> 0.3% de la imagen total), lo que + confirma que el panel se renderizo. + """ + try: + from PIL import Image + except ImportError: + pytest.skip("Pillow not installed — run: pip install Pillow") + + img = Image.open(terminal_png).convert("RGB") + pixels = img.load() + w, h = img.size + total = w * h + + # Contar pixels con al menos un canal > 60 en toda la imagen. + # Incluye la toolbar (botones), bordes, prompt "$ " y cualquier output. + light_count = sum( + 1 + for y in range(h) + for x in range(w) + if max(pixels[x, y]) > 60 # type: ignore[index] + ) + light_ratio = light_count / total + + # Umbral conservador: > 0.3% — basta con que la toolbar sea visible. + # En modo interactivo con PTY output el ratio sera mucho mayor (> 5%). + assert light_ratio >= 0.003, ( + f"Image has only {light_ratio:.2%} non-dark pixels — " + f"terminal panel may not be rendering at all. " + f"Check that fn_term::render is called and ImGui window is visible. " + f"({terminal_png})" + )