Files
fn_registry/cpp/functions/viz/terminal_panel/terminal_panel.cpp
T
egutierrez 6a318bf0c9 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 <noreply@anthropic.com>
2026-05-22 23:48:42 +02:00

309 lines
11 KiB
C++

// terminal_panel.cpp — render + process_output + shared logic.
// Los backends (open/close/send) viven en terminal_panel_linux.cpp
// y terminal_panel_windows.cpp respectivamente.
#include "viz/terminal_panel/terminal_panel.h"
#include "core/logger.h"
#include "core/tokens.h"
#include "imgui.h"
#include <algorithm>
#include <cstring>
#include <string>
namespace fn_term {
namespace {
// Convierte índice de color fn_term (0-16) a ImU32 RGBA para ImGui.
// Usa la paleta kPalette16; fg=16 (default) → color de texto del tema ImGui.
ImU32 color_to_imu32(uint8_t idx, bool is_fg) {
if (idx == kColorDefault) {
// Usar color del tema: FG → Text, BG → transparente.
if (is_fg) return ImGui::GetColorU32(ImGuiCol_Text);
return IM_COL32(0, 0, 0, 0); // transparente
}
// kPalette16 está en formato ABGR (little-endian), ImU32 también es ABGR en ImGui.
return static_cast<ImU32>(kPalette16[idx]);
}
// Renderiza una línea del scrollback con colores.
// Toma la línea como vector<AnsiCell> y escribe chunks de mismo color.
void render_line(const TermLine& line) {
if (line.empty()) {
ImGui::NewLine();
return;
}
// Agrupar celdas consecutivas con mismo fg/bg/bold y emitir como texto.
// Usamos un buffer temporal de la pila para evitar alloacs por línea.
static char buf[4096];
size_t i = 0;
while (i < line.size()) {
uint8_t fg = line[i].fg;
uint8_t bg = line[i].bg;
// uint8_t bold = line[i].bold; // TODO(0132): bold rendering v2
// Acumular chars con mismo estilo.
size_t j = i;
int pos = 0;
while (j < line.size() && line[j].fg == fg && line[j].bg == bg) {
char32_t ch = line[j].ch;
if (ch >= 0x20 && ch < 0x7F && pos < (int)sizeof(buf) - 2) {
buf[pos++] = static_cast<char>(ch);
} else if (ch != U' ' && pos < (int)sizeof(buf) - 2) {
buf[pos++] = '?'; // no-ASCII en v1
} else if (pos < (int)sizeof(buf) - 2) {
buf[pos++] = ' ';
}
j++;
}
buf[pos] = '\0';
// Push color FG.
ImU32 fg_col = color_to_imu32(fg, true);
bool has_fg = (fg != kColorDefault);
if (has_fg) ImGui::PushStyleColor(ImGuiCol_Text, fg_col);
// Fondo: si BG definido, usar InvisibleButton + DrawList rect antes del texto.
// En v1 simplificamos: solo coloreamos el texto (FG). BG requiere DrawList.
// TODO(0132): renderizar celdas BG con InvisibleButton + DrawList en v2.
ImGui::TextUnformatted(buf, buf + pos);
if (has_fg) ImGui::PopStyleColor();
// Continuar en la misma línea si hay más celdas.
if (j < line.size()) ImGui::SameLine(0.0f, 0.0f);
i = j;
}
}
} // namespace
TerminalPanel::TerminalPanel() {
// Reservar una línea inicial vacía.
lines.emplace_back();
}
TerminalPanel::~TerminalPanel() {
if (is_open()) close(*this);
}
// ---------------------------------------------------------------------------
// process_output — llamado desde el reader thread.
// Parsea los bytes via AnsiParser y actualiza el scrollback buffer.
// ---------------------------------------------------------------------------
void process_output(TerminalPanel& panel, const char* data, size_t n) {
std::lock_guard<std::mutex> lk(panel.buf_mutex);
panel.parser.feed(data, n, [&](const AnsiEvent& ev) {
switch (ev.type) {
case AnsiEventType::Char: {
// Asegurar que tenemos al menos cur_row+1 filas.
while ((int)panel.lines.size() <= panel.cur_row)
panel.lines.emplace_back();
TermLine& line = panel.lines[panel.cur_row];
// Asegurar que la fila tiene al menos cur_col+1 celdas.
while ((int)line.size() <= panel.cur_col)
line.push_back(AnsiCell{});
line[panel.cur_col] = ev.cell;
panel.cur_col++;
break;
}
case AnsiEventType::Newline: {
panel.cur_row++;
// Scrollback circular: si excede el límite, eliminar la primera fila.
while ((int)panel.lines.size() <= panel.cur_row)
panel.lines.emplace_back();
if ((int)panel.lines.size() > panel.scrollback_lines) {
int excess = (int)panel.lines.size() - panel.scrollback_lines;
panel.lines.erase(panel.lines.begin(),
panel.lines.begin() + excess);
panel.cur_row -= excess;
if (panel.cur_row < 0) panel.cur_row = 0;
}
panel.scroll_to_bottom = true;
break;
}
case AnsiEventType::CarriageReturn: {
panel.cur_col = 0;
break;
}
case AnsiEventType::Backspace: {
if (panel.cur_col > 0) panel.cur_col--;
break;
}
case AnsiEventType::CursorAbsolute: {
panel.cur_row = std::max(0, ev.cursor_abs.row);
panel.cur_col = std::max(0, ev.cursor_abs.col);
// Extender líneas si necesario.
while ((int)panel.lines.size() <= panel.cur_row)
panel.lines.emplace_back();
break;
}
case AnsiEventType::CursorMove: {
switch (ev.cursor_rel.dir) {
case CursorDir::Up:
panel.cur_row = std::max(0, panel.cur_row - ev.cursor_rel.n);
break;
case CursorDir::Down:
panel.cur_row += ev.cursor_rel.n;
while ((int)panel.lines.size() <= panel.cur_row)
panel.lines.emplace_back();
break;
case CursorDir::Forward:
panel.cur_col += ev.cursor_rel.n;
break;
case CursorDir::Back:
panel.cur_col = std::max(0, panel.cur_col - ev.cursor_rel.n);
break;
}
break;
}
case AnsiEventType::EraseDisplay: {
panel.lines.clear();
panel.lines.emplace_back();
panel.cur_row = 0;
panel.cur_col = 0;
panel.parser.reset();
break;
}
case AnsiEventType::EraseLine: {
while ((int)panel.lines.size() <= panel.cur_row)
panel.lines.emplace_back();
panel.lines[panel.cur_row].clear();
panel.cur_col = 0;
break;
}
}
});
}
// ---------------------------------------------------------------------------
// render — debe llamarse dentro de un frame ImGui activo.
// ---------------------------------------------------------------------------
void render(TerminalPanel& panel) {
// --- Toolbar ---
ImGui::PushID("##term_toolbar");
if (ImGui::SmallButton("Clear")) {
std::lock_guard<std::mutex> lk(panel.buf_mutex);
panel.lines.clear();
panel.lines.emplace_back();
panel.cur_row = 0;
panel.cur_col = 0;
}
ImGui::SameLine();
if (ImGui::SmallButton("Copy")) {
// Copiar todo el scrollback como texto plano al portapapeles.
std::string text;
std::lock_guard<std::mutex> lk(panel.buf_mutex);
for (const auto& line : panel.lines) {
for (const auto& cell : line) {
if (cell.ch >= 0x20 && cell.ch < 0x7F)
text += static_cast<char>(cell.ch);
else if (cell.ch != U' ')
text += '?';
else
text += ' ';
}
text += '\n';
}
ImGui::SetClipboardText(text.c_str());
}
ImGui::SameLine();
if (ImGui::SmallButton("Reset") && panel.is_open()) {
fn_term::close(panel);
fn_term::open(panel);
}
ImGui::SameLine();
bool lock = !panel.scroll_to_bottom;
if (ImGui::Checkbox("Lock scroll", &lock)) {
panel.scroll_to_bottom = !lock;
}
ImGui::SameLine();
// Indicador de estado del proceso.
if (!panel.is_open()) {
ImGui::TextDisabled("[closed]");
} else if (panel.process_exited.load()) {
ImGui::TextDisabled("[exited %d]", panel.exit_code);
} else {
ImGui::TextDisabled("[running]");
}
ImGui::PopID();
// --- Scrollback area — fondo negro con texto gris claro ---
ImVec2 avail = ImGui::GetContentRegionAvail();
// 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,
ImGuiWindowFlags_HorizontalScrollbar);
{
std::lock_guard<std::mutex> lk(panel.buf_mutex);
// Usar un clipper para evitar renderizar líneas fuera de vista.
ImGuiListClipper clipper;
clipper.Begin((int)panel.lines.size());
while (clipper.Step()) {
for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) {
render_line(panel.lines[i]);
}
}
clipper.End();
}
if (panel.scroll_to_bottom && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f) {
ImGui::SetScrollHereY(1.0f);
}
ImGui::EndChild();
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 (!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';
ImGui::SetKeyboardFocusHere(-1);
}
}
}
} // namespace fn_term