07252c0172
Nuevo modulo reutilizable terminal_panel (fn_term) para ImGui: Sub-fn ansi_parser_cpp_core (cpp/functions/core/): - Parser ANSI/VT100 byte-a-byte sin heap allocs por evento - SGR colores FG/BG 16-color + bold + reset - Cursor moves CUU/CUD/CUF/CUB + CUP absoluto - Erase ED(2)/EL(2), CR/LF/BS - Statemachine 4 estados, thread-unsafe por diseno - 21 tests unitarios (57 assertions), todos pasan terminal_panel_cpp_viz (cpp/functions/viz/terminal_panel/): - terminal_panel.cpp: render ImGui + process_output con list clipper - terminal_panel_linux.cpp: forkpty + reader thread no-blocking - terminal_panel_windows.cpp: ConPTY CreatePseudoConsole (SDK >= 17763) - Scrollback circular configurable (default 5000 lineas) - Toolbar: clear, copy, reset, scroll-lock + status indicator - readonly mode: sin input box, send() es no-op - uses_functions: ansi_parser_cpp_core, logger_cpp_core Tests: - test_ansi_parser.cpp: 21 test cases, 57 assertions (PASS) - test_terminal_panel_smoke.cpp: 3 test cases (PASS: spawn echo hello, process exits cleanly, readonly ignores send) CMake: - cpp/tests/CMakeLists.txt: add test_ansi_parser + test_terminal_panel_smoke - primitives_gallery (sub-repo): ver commit separado en apps/primitives_gallery Pendiente (anti-scope v1): - Windows ConPTY: stub funcional que compila; join() del reader thread via std::thread no implementado (usa CreateThread detached) - ANSI 256/24-bit color, italics, Unicode wide - Curses pesados (vim, htop, top) — cursor visible basic solo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
9.7 KiB
C++
288 lines
9.7 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 ---
|
|
ImVec2 avail = ImGui::GetContentRegionAvail();
|
|
float child_h = panel.readonly
|
|
? avail.y
|
|
: std::max(avail.y - ImGui::GetFrameHeightWithSpacing() - 4.0f, 32.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();
|
|
|
|
// --- Input box (si no es readonly) ---
|
|
if (!panel.readonly && panel.is_open()) {
|
|
static char s_input[1024] = {};
|
|
ImGui::SetNextItemWidth(-1.0f);
|
|
bool enter = ImGui::InputText("##term_input", s_input, sizeof(s_input),
|
|
ImGuiInputTextFlags_EnterReturnsTrue);
|
|
if (enter) {
|
|
std::string cmd = std::string(s_input) + "\n";
|
|
fn_term::send(panel, cmd);
|
|
s_input[0] = '\0';
|
|
ImGui::SetKeyboardFocusHere(-1);
|
|
}
|
|
}
|
|
}
|
|
|
|
} // namespace fn_term
|