diff --git a/cpp/functions/core/data_table_types.h b/cpp/functions/core/data_table_types.h index f19d27d6..b5da3c64 100644 --- a/cpp/functions/core/data_table_types.h +++ b/cpp/functions/core/data_table_types.h @@ -1,6 +1,7 @@ // data_table_types — types compartidos del stack TQL (Table Query Language). // Promovido al registry desde cpp/apps/primitives_gallery/playground/tables/. // Ver issue 0081 + docs/TQL.md. Pure value types + enums. +// Issue 0081-N: CellRenderer / ColumnSpec / BadgeRule / IconMapEntry (v1.1.0). #pragma once #include @@ -126,7 +127,55 @@ enum class ViewMode { // ---------------------------------------------------------------------------- enum class JoinStrategy { Left, Inner, Right, Full }; +// ---------------------------------------------------------------------------- +// CellRenderer: declarative rendering mode per column (issue 0081-N, v1.1.0). +// ---------------------------------------------------------------------------- +enum class CellRenderer : uint8_t { + Text = 0, // default — current behavior + Badge = 1, // colored badge per-value + Progress = 2, // progress bar (0..1 or 0..100) + Duration = 3, // milliseconds with color gradient + Icon = 4, // icon lookup by value string + // Future (Phase 2-3): Button=5, TextInput=6, Custom=7. IDs reserved. +}; + +// BadgeRule: maps a cell value to a colored badge label. +struct BadgeRule { + std::string value; // exact match (case-sensitive) + std::string color_hex; // "#rrggbb" or "rrggbb" + std::string label; // optional visual override; "" -> use value as-is +}; + +// IconMapEntry: maps a cell value to a Tabler icon macro name + optional color. +struct IconMapEntry { + std::string value; + std::string icon_name; // e.g. "TI_CHECK", "TI_X" — resolved via static map + std::string color_hex; // optional; "" -> default text color +}; + +// ColumnSpec: rendering spec for one column. Indexed by column position. +struct ColumnSpec { + std::string id; // stable id, used in TQL (future) + CellRenderer renderer = CellRenderer::Text; + + // Badge + std::vector badges; + + // Progress: cell value is float 0..1 (or 0..100 if progress_scale_100 = true) + bool progress_scale_100 = false; + std::string progress_color_hex; // override bar color; "" -> ImPlot auto + + // Duration: cell value in milliseconds (float); gradient green icon_map; +}; + +// ---------------------------------------------------------------------------- // Tabla extra pasada al render() para joins. Owner externo (caller). +// ---------------------------------------------------------------------------- struct TableInput { std::string name; // identificador estable (matchea Join.source) std::vector headers; @@ -134,6 +183,12 @@ struct TableInput { const char* const* cells = nullptr; // row-major, headers.size() cols x rows filas int rows = 0; int cols = 0; + + // NEW (issue 0081-N, v1.1.0): optional declarative renderers per column. + // empty -> all columns use default Text rendering (back-compat preserved). + // If non-empty, size must equal cols (or be 0 for "no specs"). + // column_specs are caller-managed; not persisted in TQL yet (Phase 2). + std::vector column_specs; }; // Join clause: une la tabla actual con `source` por las parejas `on`, diff --git a/cpp/functions/viz/data_table.cpp b/cpp/functions/viz/data_table.cpp new file mode 100644 index 00000000..c3a56904 --- /dev/null +++ b/cpp/functions/viz/data_table.cpp @@ -0,0 +1,4174 @@ +// data_table — render UI completa de tabla TQL. +// Entry-point publica del stack data_table del registry. +// Issue 0081-H. Promovido desde cpp/apps/primitives_gallery/playground/tables/data_table.cpp +// +// Dependencias del registry: +// - core/data_table_types.h (tipos compartidos: State, TableInput, Stage, ...) +// - core/compute_stage.h (compute_stage_cpp_core) +// - core/compute_pipeline.h (compute_pipeline_cpp_core) +// - core/compute_column_stats.h (compute_column_stats_cpp_core) +// - core/auto_detect_type.h (auto_detect_type_cpp_core) +// - core/tql_emit.h (tql_emit_cpp_core) +// - core/tql_apply.h (tql_apply_cpp_core) +// - core/lua_engine.h (lua_engine_cpp_core) +// - core/join_tables.h (join_tables_cpp_core) +// - viz/viz_render.h (viz_render_cpp_viz) +// +// Notas de deuda tecnica: +// - tql_apply_cpp_core expone firma reducida; el playground usaba tql::apply +// con cells/rows/orig_cols. Las llamadas internas de este archivo usan el +// namespace tql:: del playground via include del tql_apply_cpp_core header. +// Pendiente: ampliar tql_apply_cpp_core a la firma extendida (Wave 4/5 proposal). +// - llm_anthropic (Ask AI modal, fase 11): incluido desde el playground (no en registry). +// Pendiente: promover a cpp/functions/infra/llm_anthropic — deuda Wave 4. +// - tql_to_sql (SQL transpile): incluido desde el playground. Pendiente: registry Wave 4. +// - tql_duckdb (FN_TQL_DUCKDB): opcional, sin wrapper en registry todavia. + +#include "viz/data_table.h" + +// Framework ImGui (via fn_framework) +#include "imgui.h" + +// Registry Wave 1+2 includes (all resolved via fn_table_viz include path). +#include "core/lua_engine.h" +#include "core/tql_apply.h" +#include "core/tql_emit.h" +#include "core/tql_helpers.h" +#include "core/compute_stage.h" +#include "core/compute_pipeline.h" +#include "core/compute_column_stats.h" +#include "core/auto_detect_type.h" +#include "core/join_tables.h" +#include "core/tql_to_sql.h" +#include "viz/viz_render.h" + +// llm_anthropic — Ask AI modal. Promoted to registry (cpp/functions/core/) in +// Wave 3.5. Real implementation linked by fn_table_viz; stub kept under +// !FN_LLM_ANTHROPIC for environments that build without the lib. +#ifdef FN_LLM_ANTHROPIC +# include "core/llm_anthropic.h" +#endif + +#ifdef FN_TQL_DUCKDB +# include "tql_duckdb.h" +#endif + +// fn::local_path — from fn_framework (framework/app_base.h). +// Required by the Ask AI modal and TQL save/load paths. +#include "app_base.h" + + +#include +#include +#include +#include +#include +#include +#include +#include + +// icons_tabler.h: needed by draw_cell_custom icon renderer (issue 0081-N). +#include "core/icons_tabler.h" + +// --------------------------------------------------------------------------- +// llm_anthropic stub (Wave 4 TODO: replace with infra/llm_anthropic.h) +// Provides no-op types/functions so fn_table_viz links without the playground. +// When FN_LLM_ANTHROPIC is defined the real header is included above instead. +// --------------------------------------------------------------------------- +#ifndef FN_LLM_ANTHROPIC +namespace llm_anthropic { + enum class OutputMode { TQL, SQL }; + struct AskInput { + std::string question; + std::string tql_current; + std::vector col_names; + std::vector col_types; + std::vector joinable_names; + OutputMode mode = OutputMode::TQL; + std::string model; + int max_tokens = 8192; + }; + struct AskResult { + std::string code; + std::string raw; + std::string error; + int tokens_in = 0; + int tokens_out = 0; + }; + inline AskResult ask(const AskInput&, const std::string& = "") { + AskResult r; + r.error = "llm_anthropic not available (stub). Build with FN_LLM_ANTHROPIC=1."; + return r; + } +} // namespace llm_anthropic +#endif // FN_LLM_ANTHROPIC + +namespace data_table { + +// --------------------------------------------------------------------------- +// Helpers from playground data_table_logic — declared static so they do not +// leak into the data_table namespace beyond this translation unit. +// Promoted inline to remove dependency on playground headers. Issue 0081-I. +// --------------------------------------------------------------------------- + +// column_type_icon: returns a Tabler icon UTF-8 sequence for each ColumnType. +static const char* column_type_icon(ColumnType t) { + switch (t) { + case ColumnType::Auto: return "\xef\xa4\x9d"; // TI_HELP_CIRCLE + case ColumnType::String: return "\xef\x95\xa7"; // TI_ABC + case ColumnType::Int: return "\xef\x95\x94"; // TI_123 + case ColumnType::Float: return "\xef\xa8\xa6"; // TI_DECIMAL + case ColumnType::Bool: return "\xee\xae\xa6"; // TI_CHECKBOX + case ColumnType::Date: return "\xee\xa9\x93"; // TI_CALENDAR + case ColumnType::Json: return "\xee\xaf\x8c"; // TI_BRACES + } + return "?"; +} + +// --------------------------------------------------------------------------- +// hex_to_imcolor: parses "#rrggbb" or "rrggbb" -> ImVec4 (alpha=1). +// Returns {-1,-1,-1,-1} on failure (caller should skip color push). +// --------------------------------------------------------------------------- +static ImVec4 hex_to_imcolor(const std::string& hex) { + const char* p = hex.c_str(); + if (*p == '#') ++p; + unsigned int r = 0, g = 0, b = 0; + if (std::sscanf(p, "%02x%02x%02x", &r, &g, &b) != 3) + return ImVec4(-1.f, -1.f, -1.f, -1.f); + return ImVec4(r / 255.f, g / 255.f, b / 255.f, 1.f); +} + +// --------------------------------------------------------------------------- +// icon_name_to_glyph: static lookup of icon_name string -> Tabler glyph. +// Covers the ~30 most-used icons. Returns nullptr if not found. +// --------------------------------------------------------------------------- +static const char* icon_name_to_glyph(const std::string& name) { + static const std::unordered_map kMap = { + {"TI_CHECK", TI_CHECK}, + {"TI_X", TI_X}, + {"TI_ALERT_CIRCLE", TI_ALERT_CIRCLE}, + {"TI_CIRCLE_DOT", TI_CIRCLE_DOT}, + {"TI_CLOCK", TI_CLOCK}, + {"TI_LOADER", TI_LOADER}, + {"TI_BAN", TI_BAN}, + {"TI_PLAYER_PLAY", TI_PLAYER_PLAY}, + {"TI_PLAYER_PAUSE", TI_PLAYER_PAUSE}, + {"TI_PLAYER_STOP", TI_PLAYER_STOP}, + {"TI_DATABASE", TI_DATABASE}, + {"TI_SETTINGS", TI_SETTINGS}, + {"TI_USER", TI_USER}, + {"TI_USERS", TI_USERS}, + {"TI_FILE", TI_FILE}, + {"TI_FOLDER", TI_FOLDER}, + {"TI_REFRESH", TI_REFRESH}, + {"TI_BOLT", TI_BOLT}, + {"TI_INFO_CIRCLE", TI_INFO_CIRCLE}, + {"TI_ARROW_UP", TI_ARROW_UP}, + {"TI_ARROW_DOWN", TI_ARROW_DOWN}, + {"TI_ARROW_RIGHT", TI_ARROW_RIGHT}, + {"TI_ARROW_LEFT", TI_ARROW_LEFT}, + {"TI_DOTS", TI_DOTS}, + {"TI_EYE", TI_EYE}, + {"TI_EYE_OFF", TI_EYE_OFF}, + {"TI_EDIT", TI_EDIT}, + {"TI_TRASH", TI_TRASH}, + {"TI_COPY", TI_COPY}, + {"TI_EXTERNAL_LINK", TI_EXTERNAL_LINK}, + }; + auto it = kMap.find(name); + return it != kMap.end() ? it->second : nullptr; +} + +// --------------------------------------------------------------------------- +// draw_cell_custom: render a cell using the declarative ColumnSpec. +// Called only when spec.renderer != CellRenderer::Text. +// Issue 0081-N, v1.1.0. +// --------------------------------------------------------------------------- +static void draw_cell_custom(const ColumnSpec& spec, const char* value, + int /*row_idx*/, int /*col_idx*/) { + if (!value) value = ""; + + switch (spec.renderer) { + case CellRenderer::Badge: { + // Find a matching badge rule (exact match, case-sensitive). + const BadgeRule* matched = nullptr; + for (const auto& br : spec.badges) { + if (br.value == value) { matched = &br; break; } + } + if (matched) { + ImVec4 col = hex_to_imcolor(matched->color_hex); + const char* label = matched->label.empty() ? value : matched->label.c_str(); + if (col.x >= 0.f) { + ImGui::PushStyleColor(ImGuiCol_Header, col); + // Slightly brighter on hover to provide feedback. + ImVec4 hover_col = ImVec4( + std::min(col.x + 0.1f, 1.f), + std::min(col.y + 0.1f, 1.f), + std::min(col.z + 0.1f, 1.f), + col.w); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, hover_col); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, hover_col); + ImGui::Selectable(label, false, + ImGuiSelectableFlags_SpanAllColumns); + ImGui::PopStyleColor(3); + } else { + ImGui::TextUnformatted(label); + } + } else { + ImGui::TextUnformatted(value); + } + break; + } + + case CellRenderer::Progress: { + double v_raw = 0.0; + if (!parse_number(value, v_raw)) { + ImGui::TextUnformatted(value); + break; + } + float fv = (float)v_raw; + if (spec.progress_scale_100) fv /= 100.f; + fv = fv < 0.f ? 0.f : (fv > 1.f ? 1.f : fv); + bool has_color = !spec.progress_color_hex.empty(); + ImVec4 bar_col; + if (has_color) { + bar_col = hex_to_imcolor(spec.progress_color_hex); + if (bar_col.x < 0.f) has_color = false; + } + if (has_color) ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bar_col); + ImGui::ProgressBar(fv, ImVec2(-1.f, 0.f)); + if (has_color) ImGui::PopStyleColor(); + break; + } + + case CellRenderer::Duration: { + double v_raw = 0.0; + if (!parse_number(value, v_raw)) { + ImGui::TextUnformatted(value); + break; + } + float ms = (float)v_raw; + ImVec4 text_col; + if (ms <= spec.duration_warn_ms) { + text_col = hex_to_imcolor("#22c55e"); // green + } else if (ms <= spec.duration_error_ms) { + text_col = hex_to_imcolor("#f59e0b"); // yellow + } else { + text_col = hex_to_imcolor("#ef4444"); // red + } + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.0f ms", ms); + ImGui::TextColored(text_col, "%s", buf); + break; + } + + case CellRenderer::Icon: { + const char* glyph = nullptr; + ImVec4 icon_col(-1.f, -1.f, -1.f, -1.f); + for (const auto& entry : spec.icon_map) { + if (entry.value == value) { + glyph = icon_name_to_glyph(entry.icon_name); + if (!entry.color_hex.empty()) + icon_col = hex_to_imcolor(entry.color_hex); + break; + } + } + if (glyph) { + if (icon_col.x >= 0.f) + ImGui::TextColored(icon_col, "%s", glyph); + else + ImGui::TextUnformatted(glyph); + } else { + ImGui::TextUnformatted(value); + } + break; + } + + default: + // CellRenderer::Text or unknown — plain text. + ImGui::TextUnformatted(value); + break; + } +} + +// compare: cell-level comparison supporting all Op variants. +// Uses parse_number (from auto_detect_type.h) for numeric comparisons. +static bool compare(const char* a, const char* b, Op op) { + if (!a) a = ""; + if (!b) b = ""; + switch (op) { + case Op::Contains: return std::strstr(a, b) != nullptr; + case Op::NotContains: return std::strstr(a, b) == nullptr; + case Op::StartsWith: { + size_t lb = std::strlen(b); + return std::strncmp(a, b, lb) == 0; + } + case Op::EndsWith: { + size_t la = std::strlen(a), lb = std::strlen(b); + return lb <= la && std::strcmp(a + la - lb, b) == 0; + } + default: break; + } + double na, nb; + bool numeric = parse_number(a, na) && parse_number(b, nb); + if (numeric) { + switch (op) { + case Op::Eq: return na == nb; + case Op::Neq: return na != nb; + case Op::Gt: return na > nb; + case Op::Gte: return na >= nb; + case Op::Lt: return na < nb; + case Op::Lte: return na <= nb; + default: break; + } + } + int c = std::strcmp(a, b); + switch (op) { + case Op::Eq: return c == 0; + case Op::Neq: return c != 0; + case Op::Gt: return c > 0; + case Op::Gte: return c >= 0; + case Op::Lt: return c < 0; + case Op::Lte: return c <= 0; + default: break; + } + return false; +} + +// make_drill_filter: creates an Op::Eq filter on col_idx with the given value. +static Filter make_drill_filter(int col_idx, const std::string& value) { + Filter f; + f.col = col_idx; + f.op = Op::Eq; + f.value = value; + return f; +} + +// apply_drill_step: inserts step.added into st at the recorded position. +static bool apply_drill_step(State& st, const DrillStep& step) { + if (step.target_stage < 0 || step.target_stage >= (int)st.stages.size()) return false; + Stage& s = st.stages[step.target_stage]; + int pos = step.filter_pos; + if (pos < 0 || pos > (int)s.filters.size()) return false; + s.filters.insert(s.filters.begin() + pos, step.added); + st.active_stage = step.target_stage; + return true; +} + +// undo_drill_step: removes the filter inserted by apply_drill_step. +static bool undo_drill_step(State& st, const DrillStep& step) { + if (step.target_stage < 0 || step.target_stage >= (int)st.stages.size()) return false; + Stage& s = st.stages[step.target_stage]; + int pos = step.filter_pos; + if (pos < 0 || pos >= (int)s.filters.size()) return false; + s.filters.erase(s.filters.begin() + pos); + if (step.prev_active_stage >= 0 && step.prev_active_stage < (int)st.stages.size()) + st.active_stage = step.prev_active_stage; + return true; +} + +// drill_up: decrements active_stage by 1 if possible. +static bool drill_up(State& st) { + if (st.stages.empty()) return false; + if (st.active_stage <= 0) return false; + st.active_stage -= 1; + return true; +} + +// row_to_tsv: serializes a single row to a two-line TSV (header + values). +static std::string row_to_tsv(const char* const* cells, int rows, int cols, + int row_idx, const std::vector& headers) { + if (row_idx < 0 || row_idx >= rows || cols <= 0) return ""; + std::string out; + for (int c = 0; c < cols; ++c) { + if (c > 0) out += '\t'; + if (c < (int)headers.size()) out += headers[c]; + } + out += "\r\n"; + for (int c = 0; c < cols; ++c) { + if (c > 0) out += '\t'; + const char* v = cells[row_idx * cols + c]; + if (v) out += v; + } + out += "\r\n"; + return out; +} + +// compute_visible_rows: applies stage-0 filters + optional sort, returns matching row indices. +static std::vector compute_visible_rows(const char* const* cells, + int rows, int cols, + const State& st) +{ + std::vector out; + out.reserve(rows); + const Stage& s = st.raw(); + for (int r = 0; r < rows; ++r) { + bool keep = true; + for (const auto& f : s.filters) { + if (f.col < 0 || f.col >= cols) continue; + const char* cell = cells[r * cols + f.col]; + if (!compare(cell, f.value.c_str(), f.op)) { keep = false; break; } + } + if (keep) out.push_back(r); + } + if (!s.sorts.empty()) { + const SortClause& sc0 = s.sorts.front(); + int sc = -1; + if (!sc0.col.empty() && sc0.col[0] == '@') { + sc = std::atoi(sc0.col.c_str() + 1); + } + bool desc = sc0.desc; + if (sc >= 0 && sc < cols) { + std::sort(out.begin(), out.end(), [&](int a, int b) { + const char* ca = cells[a * cols + sc]; + const char* cb = cells[b * cols + sc]; + if (!ca) ca = ""; + if (!cb) cb = ""; + double na, nb; + bool num = parse_number(ca, na) && parse_number(cb, nb); + int cmp; + if (num) cmp = (na < nb) ? -1 : (na > nb ? 1 : 0); + else cmp = std::strcmp(ca, cb); + return desc ? (cmp > 0) : (cmp < 0); + }); + } + } + return out; +} + +// csv_escape: wraps s in double-quotes if it contains commas, quotes, or newlines. +static std::string csv_escape(const char* s) { + if (!s) return ""; + bool needs = false; + for (const char* p = s; *p; ++p) { + if (*p == ',' || *p == '"' || *p == '\n' || *p == '\r') { needs = true; break; } + } + if (!needs) return std::string(s); + std::string out; out.reserve(std::strlen(s) + 4); + out += '"'; + for (const char* p = s; *p; ++p) { + if (*p == '"') out += '"'; + out += *p; + } + out += '"'; + return out; +} + +// reorder_column: moves col src to position of col dst in st.col_order. +static void reorder_column(State& st, int src, int dst) { + if (src == dst) return; + auto it_s = std::find(st.col_order.begin(), st.col_order.end(), src); + auto it_d = std::find(st.col_order.begin(), st.col_order.end(), dst); + if (it_s == st.col_order.end() || it_d == st.col_order.end()) return; + int si = (int)(it_s - st.col_order.begin()); + int di = (int)(it_d - st.col_order.begin()); + int v = st.col_order[si]; + st.col_order.erase(st.col_order.begin() + si); + if (di > (int)st.col_order.size()) di = (int)st.col_order.size(); + st.col_order.insert(st.col_order.begin() + di, v); +} + +// find_open_bracket: scans buf[0..cursor) backwards for an unmatched '['. +// Returns index of '[' and fills filter_text with content after it, or -1 if none. +static int find_open_bracket(const char* buf, int len, int cursor, std::string& filter_text) { + filter_text.clear(); + if (!buf || cursor <= 0 || cursor > len) return -1; + for (int i = cursor - 1; i >= 0; --i) { + char c = buf[i]; + if (c == ']' || c == '\n') return -1; + if (c == '[') { + filter_text.assign(buf + i + 1, cursor - i - 1); + return i; + } + } + return -1; +} + +// insert_column_ref: replaces src[start..cursor) with "[name]", updating new_cursor. +static std::string insert_column_ref(const std::string& src, int start, int cursor, + const std::string& name, int& new_cursor) { + if (start < 0 || start > (int)src.size() || cursor < start || cursor > (int)src.size()) { + new_cursor = cursor; + return src; + } + std::string replacement = "[" + name + "]"; + std::string out; + out.reserve(src.size() - (cursor - start) + replacement.size()); + out.append(src, 0, start); + out += replacement; + out.append(src, cursor, std::string::npos); + new_cursor = start + (int)replacement.size(); + return out; +} + +// --------------------------------------------------------------------------- +// Additional helpers from playground data_table_logic — view_mode, joins, +// filter presets, date helpers, effective_type, etc. +// All declared static to stay internal to this translation unit. +// --------------------------------------------------------------------------- + +static ColumnType effective_type(ColumnType declared, + const char* const* cells, int rows, int cols, int col) { + if (declared != ColumnType::Auto) return declared; + return auto_detect_type(cells, rows, cols, col); +} + +static std::vector ops_for_type(ColumnType t) { + switch (t) { + case ColumnType::Int: + case ColumnType::Float: + case ColumnType::Date: + return {Op::Eq, Op::Neq, Op::Gt, Op::Gte, Op::Lt, Op::Lte}; + case ColumnType::Bool: + return {Op::Eq, Op::Neq}; + case ColumnType::Json: + return {Op::Eq, Op::Neq, Op::Contains, Op::NotContains}; + case ColumnType::String: + return {Op::Eq, Op::Neq, Op::Contains, Op::NotContains, Op::StartsWith, Op::EndsWith}; + case ColumnType::Auto: + default: + return {Op::Eq, Op::Neq, Op::Contains, Op::NotContains}; + } +} + +static int resolve_main_idx(const std::vector& tables, const std::string& main_source) { + if (tables.empty()) return -1; + if (main_source.empty()) return 0; + for (size_t i = 0; i < tables.size(); ++i) { + if (tables[i].name == main_source) return (int)i; + } + return 0; +} + +static const char* join_strategy_label(JoinStrategy s) { + switch (s) { + case JoinStrategy::Left: return "left-join"; + case JoinStrategy::Inner: return "inner-join"; + case JoinStrategy::Right: return "right-join"; + case JoinStrategy::Full: return "full-join"; + } + return "left-join"; +} + +struct ViewModeInfo { + ViewMode m; + const char* token; + const char* label; + int min_cols; + bool needs_num; + bool needs_cat; + bool needs_agg; +}; + +static const ViewModeInfo kViewModes[] = { + { ViewMode::Table, "table", "Table", 1, false, false, false }, + { ViewMode::Bar, "bar", "Bar (horizontal)", 2, true, true, true }, + { ViewMode::Column, "column", "Column (vertical)", 2, true, true, true }, + { ViewMode::GroupedBar, "grouped_bar", "Grouped bar", 2, true, true, true }, + { ViewMode::StackedBar, "stacked_bar", "Stacked bar", 2, true, true, true }, + { ViewMode::Line, "line", "Line", 1, true, false, false }, + { ViewMode::Area, "area", "Area", 1, true, false, false }, + { ViewMode::Stairs, "stairs", "Stairs", 1, true, false, false }, + { ViewMode::Scatter, "scatter", "Scatter", 2, true, false, false }, + { ViewMode::Bubble, "bubble", "Bubble", 3, true, false, false }, + { ViewMode::Histogram, "histogram", "Histogram", 1, true, false, false }, + { ViewMode::Histogram2D, "hist2d", "Histogram 2D", 2, true, false, false }, + { ViewMode::Heatmap, "heatmap", "Heatmap", 1, true, false, false }, + { ViewMode::BoxPlot, "boxplot", "Box plot", 2, true, true, false }, + { ViewMode::Stem, "stem", "Stem", 1, true, false, false }, + { ViewMode::ErrorBars, "errorbars", "Error bars", 2, true, false, false }, + { ViewMode::Pie, "pie", "Pie", 2, true, true, true }, + { ViewMode::Donut, "donut", "Donut", 2, true, true, true }, + { ViewMode::Funnel, "funnel", "Funnel", 2, true, true, true }, + { ViewMode::Waterfall, "waterfall", "Waterfall", 1, true, false, true }, + { ViewMode::KPI, "kpi", "KPI (single)", 1, true, false, true }, + { ViewMode::KPIGrid, "kpi_grid", "KPI grid", 1, true, false, true }, + { ViewMode::Candlestick, "candlestick", "Candlestick (OHLC)", 4, true, false, false }, + { ViewMode::Radar, "radar", "Radar", 2, true, true, false }, +}; +static const int kViewModesN = (int)(sizeof(kViewModes) / sizeof(kViewModes[0])); + +static const char* view_mode_label(ViewMode m) { + for (int i = 0; i < kViewModesN; ++i) if (kViewModes[i].m == m) return kViewModes[i].label; + return "Table"; +} + +static bool view_mode_needs_aggregation(ViewMode m) { + for (int i = 0; i < kViewModesN; ++i) if (kViewModes[i].m == m) return kViewModes[i].needs_agg; + return false; +} + +static const ViewMode* all_view_modes(int* n_out) { + static ViewMode arr[64]; + static bool init = false; + if (!init) { + for (int i = 0; i < kViewModesN; ++i) arr[i] = kViewModes[i].m; + init = true; + } + if (n_out) *n_out = kViewModesN; + return arr; +} + +// Date helpers (for filter presets and breakout auto-granularity). + +namespace { + +static bool parse_ymd_local(const std::string& s, int& y, int& m, int& d) { + if (s.size() < 10) return false; + for (int i : {0,1,2,3,5,6,8,9}) { + if (s[(size_t)i] < '0' || s[(size_t)i] > '9') return false; + } + if (s[4] != '-' || s[7] != '-') return false; + y = (s[0]-'0')*1000 + (s[1]-'0')*100 + (s[2]-'0')*10 + (s[3]-'0'); + m = (s[5]-'0')*10 + (s[6]-'0'); + d = (s[8]-'0')*10 + (s[9]-'0'); + if (m < 1 || m > 12 || d < 1 || d > 31) return false; + return true; +} + +static long ymd_to_days_local(int y, int m, int d) { + if (m <= 2) { y -= 1; m += 12; } + long era = (y >= 0 ? y : y - 399) / 400; + unsigned yoe = (unsigned)(y - era * 400); + unsigned doy = (unsigned)((153 * (m - 3) + 2) / 5 + d - 1); + unsigned doe = yoe * 365 + yoe/4 - yoe/100 + doy; + return era * 146097 + (long)doe; +} + +static void days_to_ymd_local(long days, int& y, int& m, int& d) { + long era = (days >= 0 ? days : days - 146096) / 146097; + unsigned doe = (unsigned)(days - era * 146097); + unsigned yoe = (doe - doe/1460 + doe/36524 - doe/146096) / 365; + int yr = (int)yoe + (int)era * 400; + unsigned doy = doe - (365*yoe + yoe/4 - yoe/100); + unsigned mp = (5*doy + 2)/153; + unsigned day = doy - (153*mp + 2)/5 + 1; + unsigned mon = mp < 10 ? mp + 3 : mp - 9; + if (mon <= 2) yr += 1; + y = yr; m = (int)mon; d = (int)day; +} + +} // anon + +static void column_min_max(const char* const* cells, int rows, int cols, int col_idx, + std::string& min_out, std::string& max_out) { + min_out.clear(); max_out.clear(); + if (col_idx < 0 || col_idx >= cols) return; + bool first = true; + for (int r = 0; r < rows; ++r) { + const char* v = cells[r * cols + col_idx]; + if (!v || !*v) continue; + std::string s(v); + if (first) { min_out = s; max_out = s; first = false; } + else { if (s < min_out) min_out = s; if (s > max_out) max_out = s; } + } +} + +static DateGranularity auto_date_granularity(const std::string& min_ymd, + const std::string& max_ymd) { + int y1,m1,d1, y2,m2,d2; + if (!parse_ymd_local(min_ymd, y1,m1,d1)) return DateGranularity::Day; + if (!parse_ymd_local(max_ymd, y2,m2,d2)) return DateGranularity::Day; + long span = ymd_to_days_local(y2,m2,d2) - ymd_to_days_local(y1,m1,d1); + if (span < 0) span = -span; + if (span > 730) return DateGranularity::Year; + if (span > 60) return DateGranularity::Month; + if (span > 14) return DateGranularity::Week; + return DateGranularity::Day; +} + +static std::string compose_breakout(const std::string& col, DateGranularity g) { + if (g == DateGranularity::None) return col; + return col + ":" + date_granularity_token(g); +} + +static const char* filter_preset_label(FilterPreset p) { + switch (p) { + case FilterPreset::Last7d: return "Last 7 days"; + case FilterPreset::Last30d: return "Last 30 days"; + case FilterPreset::Last90d: return "Last 90 days"; + case FilterPreset::ExcludeNulls: return "Exclude nulls"; + case FilterPreset::NonZero: return "Non-zero only"; + } + return "?"; +} + +static std::vector build_preset_filters(FilterPreset preset, int col, + const std::string& today_ymd) { + std::vector out; + auto last_n = [&](int n) { + int y, m, d; + if (!parse_ymd_local(today_ymd, y, m, d)) return; + long days = ymd_to_days_local(y, m, d) - n; + int yy, mm, dd; + days_to_ymd_local(days, yy, mm, dd); + char buf[16]; + std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", yy, mm, dd); + Filter f; f.col = col; f.op = Op::Gte; f.value = buf; + out.push_back(f); + }; + switch (preset) { + case FilterPreset::Last7d: last_n(7); break; + case FilterPreset::Last30d: last_n(30); break; + case FilterPreset::Last90d: last_n(90); break; + case FilterPreset::ExcludeNulls: { + Filter f; f.col = col; f.op = Op::Neq; f.value = ""; + out.push_back(f); break; + } + case FilterPreset::NonZero: { + Filter f1; f1.col = col; f1.op = Op::Neq; f1.value = ""; + Filter f2; f2.col = col; f2.op = Op::Neq; f2.value = "0"; + out.push_back(f1); out.push_back(f2); break; + } + } + return out; +} + +// agg_fn_name, op_is_string_only — small helpers not in tql_helpers.h. +// op_label and aggregation_alias are already provided by tql_helpers.h. + +static const char* agg_fn_name(AggFn f) { + switch (f) { + case AggFn::Count: return "count"; + case AggFn::Sum: return "sum"; + case AggFn::Avg: return "avg"; + case AggFn::Min: return "min"; + case AggFn::Max: return "max"; + case AggFn::Distinct: return "distinct"; + case AggFn::Stddev: return "stddev"; + case AggFn::Median: return "median"; + case AggFn::P25: return "p25"; + case AggFn::P75: return "p75"; + case AggFn::P90: return "p90"; + case AggFn::P99: return "p99"; + case AggFn::Percentile: return "percentile"; + } + return "?"; +} + +static bool op_is_string_only(Op o) { + return o == Op::Contains || o == Op::NotContains || + o == Op::StartsWith || o == Op::EndsWith; +} + +// UTC date today as ISO YYYY-MM-DD. Para preset filtros Last7/30/90d. +static std::string today_iso() { + std::time_t t = std::time(nullptr); + std::tm tm = *std::gmtime(&t); + char buf[16]; + std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday); + return buf; +} + +namespace { + +// --------------------------------------------------------------------------- +// UI state global por-instancia (singleton playground). +// --------------------------------------------------------------------------- +struct UiState { + int pending_col = -1; + std::string pending_value; + bool open_cell_popup = false; + + int header_popup_col = -1; + std::unordered_map filter_inputs; + std::unordered_map color_value_inputs; + std::unordered_map color_picker_vals; + + int addf_col = 0; + std::string addf_val; + bool addf_range = false; + std::string addf_lo; + std::string addf_hi; + + int sel_anchor_row = -1; + int sel_anchor_col = -1; + int sel_end_row = -1; + int sel_end_col = -1; + bool sel_active = false; + bool sel_dragging = false; + + std::string last_export_path; + + // Modal de columna custom (formula). + bool cf_open = false; + bool cf_editing = false; + int cf_edit_idx = -1; + int cf_target_stage = 0; // stage donde se guarda la formula + std::string cf_formula; + std::string cf_name; + ColumnType cf_type = ColumnType::String; + std::string cf_error; + + bool cf_ac_open = false; + int cf_ac_start = -1; + int cf_ac_cursor = -1; + std::string cf_ac_filter; + bool cf_force_cursor = false; + int cf_target_cursor = -1; + + // TQL modales. + bool tql_show_open = false; + std::string tql_show_text; + bool tql_apply_open = false; + std::string tql_apply_text; + std::string tql_apply_error; + char tql_file_path[256] = "table.tql"; + std::string tql_io_status; // mensaje "saved" / "loaded" / error + + // Add-breakout popup (stage > 0). + int brk_picker_col = 0; + + // Add-aggregation popup (stage > 0). + int agg_picker_fn = (int)AggFn::Count; + int agg_picker_col = 0; + double agg_picker_arg = 0.95; + + // Edit chip popups: click der sobre chip. + // 0=none, 1=filter, 2=breakout, 3=agg, 4=sort + int edit_chip_kind = 0; + int edit_chip_idx = -1; + int edit_col_idx = 0; // combo idx para col picker + int edit_op = (int)Op::Eq; + int edit_agg_fn = (int)AggFn::Count; + double edit_agg_arg = 0.5; + bool edit_sort_desc = false; + std::string edit_value; + + // Add-sort popup (any stage). + int sort_picker_col = 0; + bool sort_picker_desc = false; + + bool stats_mode = false; + std::vector stats_cache; + const char* const* last_cells = nullptr; + int last_rows = -1; + int last_eff_cols = -1; + size_t last_filter_h = (size_t)-1; + int last_visible = -1; + + // Snapshot del active stage output para el config popup. + std::vector active_headers; + std::vector active_types; + // Snapshot del INPUT del active stage (= output del previo o orig+derived + // si active==0). Para que el config popup pueda cambiar la categoria del + // breakout del stage activo eligiendo de las cols upstream. + std::vector input_headers_active; + std::vector input_types_active; + + // Para forzar re-fit en cambio de display/stage/config. + ViewMode prev_viz_display = ViewMode::Table; + int prev_viz_stage = 0; + size_t prev_viz_cfg_h = 0; + + // show_chrome user override. Default: chips bar closed — user opens via + // "Show UI" button. Cached as user-set so the API arg show_chrome is + // bypassed from frame 1. + bool chrome_user_set = true; + bool chrome_user_visible = false; + + // Toggle Table <-> View: remember last non-table display. + ViewMode last_non_table_main = ViewMode::Bar; + + // Drill history (fase 10). Stacks per-app; no persistido en TQL. + std::vector drill_back; + std::vector drill_forward; + + // Row inspector (fase 10). -1 cerrado, sino row idx en el output del stage activo. + int inspect_row = -1; + bool inspect_open = false; + + // Ask AI modal (fase 11 — issue 0080). + bool ask_open = false; + bool ask_busy = false; + int ask_mode = 0; // 0 = TQL, 1 = SQL + char ask_question[2048] = {0}; + std::string ask_current_tql; // emit del state actual al abrir modal + std::string ask_response_raw; // texto del modelo + std::string ask_response_code; // bloque extraido (Lua o SQL) + std::string ask_error; + std::string ask_status; // "Sent. Waiting..." / "OK" / error + char ask_edit_buf[8192] = {0}; // buffer editable de propuesta +}; + +UiState& ui() { static UiState s; return s; } + +// Row inspector modal (fase 10). Muestra todas cols + valores de la fila +// inspect_row del output del stage activo. Read-only + Copy TSV + Filter +// by this row (anade filters al stage previo si existe). +static void draw_row_inspector_modal(State& st, int active, + const char* const* cells, int rows, int cols, + const std::vector& headers, + const std::vector& types, + const std::vector& prev_input_headers) { + auto& U = ui(); + if (!U.inspect_open) return; + if (U.inspect_row < 0 || U.inspect_row >= rows) { + U.inspect_open = false; + return; + } + ImGui::OpenPopup("##row_inspector"); + ImGui::SetNextWindowSize(ImVec2(560, 400), ImGuiCond_Appearing); + if (ImGui::BeginPopupModal("##row_inspector", &U.inspect_open, + ImGuiWindowFlags_NoSavedSettings)) { + ImGui::Text("Row %d", U.inspect_row); + ImGui::SameLine(0, 20); + if (ImGui::SmallButton("Copy TSV")) { + std::string tsv = row_to_tsv(cells, rows, cols, U.inspect_row, headers); + ImGui::SetClipboardText(tsv.c_str()); + } + ImGui::SameLine(); + bool can_filter = (active > 0 && !prev_input_headers.empty()); + ImGui::BeginDisabled(!can_filter); + if (ImGui::SmallButton("Filter prev stage by this row")) { + int target = active - 1; + for (int c = 0; c < cols; ++c) { + const char* v = cells[U.inspect_row * cols + c]; + if (!v || !*v) continue; + const std::string& h = headers[c]; + std::string h_clean; + parse_breakout_granularity(h, h_clean); + int ci = -1; + for (size_t i = 0; i < prev_input_headers.size(); ++i) { + if (prev_input_headers[i] == h_clean) { ci = (int)i; break; } + } + if (ci < 0) continue; + DrillStep step; + step.target_stage = target; + step.filter_pos = (int)st.stages[target].filters.size(); + step.prev_active_stage = st.active_stage; + step.added = make_drill_filter(ci, v); + if (apply_drill_step(st, step)) { + U.drill_back.push_back(step); + } + } + U.drill_forward.clear(); + U.inspect_open = false; + } + ImGui::EndDisabled(); + ImGui::Separator(); + ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg + | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable; + if (ImGui::BeginTable("##inspector_tbl", 2, flags, ImVec2(-1, -1))) { + ImGui::TableSetupColumn("col"); + ImGui::TableSetupColumn("value"); + ImGui::TableHeadersRow(); + for (int c = 0; c < cols; ++c) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ColumnType t = (c < (int)types.size()) ? types[c] : ColumnType::String; + ImGui::Text("%s %s", column_type_icon(t), + (c < (int)headers.size()) ? headers[c].c_str() : "?"); + ImGui::TableSetColumnIndex(1); + const char* v = cells[U.inspect_row * cols + c]; + ImGui::TextWrapped("%s", v ? v : ""); + } + ImGui::EndTable(); + } + ImGui::EndPopup(); + } +} + +int autocomplete_cb(ImGuiInputTextCallbackData* data) { + UiState* U = (UiState*)data->UserData; + if (data->EventFlag == ImGuiInputTextFlags_CallbackAlways) { + if (U->cf_force_cursor) { + data->CursorPos = U->cf_target_cursor; + U->cf_force_cursor = false; + } + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackEdit) { + std::string filter; + int idx = find_open_bracket(data->Buf, data->BufTextLen, + data->CursorPos, filter); + if (idx >= 0) { + U->cf_ac_open = true; + U->cf_ac_start = idx; + U->cf_ac_cursor = data->CursorPos; + U->cf_ac_filter = filter; + } else { + U->cf_ac_open = false; + } + } + return 0; +} + +size_t filters_hash(const std::vector& f) { + size_t h = 0xcbf29ce484222325ULL; + for (const auto& x : f) { + h ^= (size_t)x.col; h *= 0x100000001b3ULL; + h ^= (size_t)x.op; h *= 0x100000001b3ULL; + for (char ch : x.value) { h ^= (size_t)(unsigned char)ch; h *= 0x100000001b3ULL; } + } + return h; +} + +void ensure_init(State& st, int eff_cols) { + if ((int)st.col_visible.size() < eff_cols) st.col_visible.resize(eff_cols, true); + if ((int)st.col_order.size() != eff_cols) { + std::vector next; + next.reserve(eff_cols); + for (int x : st.col_order) if (x >= 0 && x < eff_cols) next.push_back(x); + std::vector seen(eff_cols, false); + for (int x : next) seen[x] = true; + for (int i = 0; i < eff_cols; ++i) if (!seen[i]) next.push_back(i); + st.col_order = std::move(next); + } + if ((int)st.col_visible.size() < (int)st.col_order.size()) + st.col_visible.resize(st.col_order.size(), true); +} + +// --------------------------------------------------------------------------- +// Breadcrumb stages: fila de botones Raw > Stage 1 > Stage 2 ... [+ Stage] +// --------------------------------------------------------------------------- +void draw_stage_breadcrumb(State& st) { + st.ensure_stage0(); + + // Drill history back/forward (fase 10). Botones al inicio. + auto& U = ui(); + { + bool can_back = !U.drill_back.empty(); + ImGui::BeginDisabled(!can_back); + if (ImGui::SmallButton("<##drill_back")) { + DrillStep s = U.drill_back.back(); + U.drill_back.pop_back(); + if (undo_drill_step(st, s)) { + U.drill_forward.push_back(s); + } + } + ImGui::EndDisabled(); + if (can_back && ImGui::IsItemHovered()) + ImGui::SetTooltip("Drill back (%zu)", U.drill_back.size()); + ImGui::SameLine(); + bool can_fwd = !U.drill_forward.empty(); + ImGui::BeginDisabled(!can_fwd); + if (ImGui::SmallButton(">##drill_fwd")) { + DrillStep s = U.drill_forward.back(); + U.drill_forward.pop_back(); + if (apply_drill_step(st, s)) { + U.drill_back.push_back(s); + } + } + ImGui::EndDisabled(); + if (can_fwd && ImGui::IsItemHovered()) + ImGui::SetTooltip("Drill forward (%zu)", U.drill_forward.size()); + ImGui::SameLine(); + bool can_up = (st.active_stage > 0); + ImGui::BeginDisabled(!can_up); + if (ImGui::SmallButton("^##drill_up")) drill_up(st); + ImGui::EndDisabled(); + if (can_up && ImGui::IsItemHovered()) + ImGui::SetTooltip("Drill up (stage previo, sin perder filters)"); + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + } + + for (int si = 0; si < (int)st.stages.size(); ++si) { + if (si > 0) { ImGui::SameLine(); ImGui::TextDisabled(">"); ImGui::SameLine(); } + + bool active = (si == st.active_stage); + if (active) { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 80, 140, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(100, 160, 220, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 60, 120, 180, 240)); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 70, 70, 90, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 90, 90, 120, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 55, 55, 75, 220)); + } + + char label[256]; + if (si == 0) { + std::snprintf(label, sizeof(label), "Raw##stage%d", si); + } else { + const Stage& s = st.stages[si]; + std::string desc; + for (size_t i = 0; i < s.breakouts.size() && i < 2; ++i) { + if (i > 0) desc += ", "; + desc += s.breakouts[i]; + } + if (s.breakouts.size() > 2) desc += "..."; + if (desc.empty()) + std::snprintf(label, sizeof(label), "Stage %d##s%d", si, si); + else + std::snprintf(label, sizeof(label), "Stage %d: by %s##s%d", + si, desc.c_str(), si); + } + if (ImGui::Button(label)) st.active_stage = si; + ImGui::PopStyleColor(3); + + if (si > 0) { + ImGui::SameLine(); + char xlbl[32]; + std::snprintf(xlbl, sizeof(xlbl), "x##rm_s%d", si); + if (ImGui::SmallButton(xlbl)) { + // borra ese stage y sucesores + while ((int)st.stages.size() > si) st.stages.pop_back(); + if (st.active_stage >= (int)st.stages.size()) + st.active_stage = (int)st.stages.size() - 1; + if (st.active_stage < 0) st.active_stage = 0; + break; + } + } + } + ImGui::SameLine(); + ImGui::TextDisabled(">"); + ImGui::SameLine(); + if (ImGui::SmallButton("+ Stage##add_stage")) { + st.stages.push_back(Stage{}); + st.active_stage = (int)st.stages.size() - 1; + } +} + +struct ColInfo { std::string name; ColumnType type; }; +std::vector collect_active_col_info(const State& st); + +// Auto-promote: si user en stage 0 elige una viz que necesita agrupacion, +// crea stage 1 con breakout=primera cat + agg=sum(primera num) o count. +void auto_promote_aggregated(State& st) { + auto& U = ui(); + if (st.active_stage != 0) return; + if (st.stages.size() != 1) return; + + std::string cat_name; + std::string num_name; + for (size_t i = 0; i < U.active_headers.size() && i < U.active_types.size(); ++i) { + ColumnType t = U.active_types[i]; + if (cat_name.empty() && + (t == ColumnType::String || t == ColumnType::Date || + t == ColumnType::Bool || t == ColumnType::Json)) { + cat_name = U.active_headers[i]; + } + if (num_name.empty() && + (t == ColumnType::Int || t == ColumnType::Float)) { + num_name = U.active_headers[i]; + } + } + + Stage s1; + if (!cat_name.empty()) s1.breakouts.push_back(cat_name); + Aggregation a; + if (!num_name.empty()) { + a.fn = AggFn::Sum; + a.col = num_name; + } else { + a.fn = AggFn::Count; + } + s1.aggregations.push_back(a); + st.stages.push_back(std::move(s1)); + st.active_stage = (int)st.stages.size() - 1; +} + +// Toggle simple: un solo boton que alterna entre Table y el last_non_table. +// Para el main pasa st (para poder auto-promote a stage agregado si la viz +// destino lo requiere). Para extras usar overload sin State. +void draw_table_toggle(ViewMode& display, ViewMode& last_non_table, + const char* id_suffix, State* st_opt = nullptr) { + bool is_table = (display == ViewMode::Table); + char b[64]; + std::snprintf(b, sizeof(b), "%s##tbl_%s", + is_table ? "Show chart" : "Show table", id_suffix); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(80, 140, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(100, 160, 220, 240)); + if (ImGui::SmallButton(b)) { + if (is_table) { + ViewMode tgt = (last_non_table == ViewMode::Table) + ? ViewMode::Bar : last_non_table; + display = tgt; + if (st_opt && view_mode_needs_aggregation(tgt)) { + auto_promote_aggregated(*st_opt); + } + } else { + last_non_table = display; + display = ViewMode::Table; + } + } + ImGui::PopStyleColor(2); +} + +// Render extra viz panel: child window con toolbar mini + chart. +// Devuelve true si user pidio cerrar. +bool draw_extra_panel(State& st, VizPanel& p, int idx, const StageOutput& so, + const std::vector* col_specs = nullptr) { + bool close_req = false; + char child_id[64]; std::snprintf(child_id, sizeof(child_id), "##extra_viz_%d", idx); + ImGui::BeginChild(child_id, ImVec2(0, 320), true); + + // Toolbar + int n_modes = 0; + const ViewMode* modes = all_view_modes(&n_modes); + ImGui::TextDisabled("View:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(180); + char combo_id[64]; std::snprintf(combo_id, sizeof(combo_id), "##ev_mode_%d", idx); + if (ImGui::BeginCombo(combo_id, view_mode_label(p.display))) { + for (int i = 0; i < n_modes; ++i) { + bool sel = (modes[i] == p.display); + if (ImGui::Selectable(view_mode_label(modes[i]), sel)) { + p.display = modes[i]; + p.config.fit_request = true; + } + if (sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + char fit_id[32]; std::snprintf(fit_id, sizeof(fit_id), "Fit##ev_fit_%d", idx); + if (ImGui::SmallButton(fit_id)) p.config.fit_request = true; + ImGui::SameLine(); + char lock_id[32]; std::snprintf(lock_id, sizeof(lock_id), "%s##ev_lock_%d", + p.config.locked ? "Locked" : "Lock", idx); + if (p.config.locked) { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(180, 60, 60, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(200, 80, 80, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(150, 40, 40, 240)); + } + if (ImGui::SmallButton(lock_id)) p.config.locked = !p.config.locked; + if (p.config.locked) ImGui::PopStyleColor(3); + ImGui::SameLine(); + char close_id[32]; std::snprintf(close_id, sizeof(close_id), "X##ev_close_%d", idx); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(120, 50, 50, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(160, 70, 70, 240)); + if (ImGui::SmallButton(close_id)) close_req = true; + ImGui::PopStyleColor(2); + + // Toggle Table <-> View per-panel + char ts[32]; std::snprintf(ts, sizeof(ts), "ep%d", idx); + draw_table_toggle(p.display, p.last_non_table, ts); + + // Render: si Table -> mini table; else chart. + if (p.display == ViewMode::Table) { + ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY | + ImGuiTableFlags_ScrollX; + char tid[64]; std::snprintf(tid, sizeof(tid), "##ep_table_%d", idx); + if (so.cols > 0 && ImGui::BeginTable(tid, so.cols, flags, ImVec2(0, 0))) { + for (int c = 0; c < so.cols; ++c) + ImGui::TableSetupColumn(so.headers[c].c_str()); + ImGui::TableHeadersRow(); + for (int r = 0; r < so.rows; ++r) { + ImGui::TableNextRow(); + for (int c = 0; c < so.cols; ++c) { + ImGui::TableSetColumnIndex(c); + const char* s = so.cells[(size_t)r * so.cols + c]; + // Issue 0081-N: declarative renderer for extra panel mini-table. + bool custom_ep = false; + if (col_specs && c < (int)col_specs->size()) { + const ColumnSpec& cs = (*col_specs)[(size_t)c]; + if (cs.renderer != CellRenderer::Text) { + draw_cell_custom(cs, s, r, c); + custom_ep = true; + } + } + if (!custom_ep) ImGui::TextUnformatted(s ? s : ""); + } + } + ImGui::EndTable(); + } + } else { + viz::render(so, p.display, p.config, ImVec2(-1, -1)); + } + + ImGui::EndChild(); + (void)st; + return close_req; +} + +void draw_viz_config_popup(State& st) { + if (!ImGui::BeginPopup("##viz_cfg_popup")) return; + ImGui::Text("Configure: %s", view_mode_label(st.display)); + ImGui::Separator(); + + auto cols = collect_active_col_info(st); + std::vector all_names; + std::vector num_names; + std::vector cat_names; + for (auto& c : cols) { + all_names.push_back(c.name.c_str()); + if (c.type == ColumnType::Int || c.type == ColumnType::Float) + num_names.push_back(c.name.c_str()); + else + cat_names.push_back(c.name.c_str()); + } + + auto& vc = st.viz_config; + ViewMode m = st.display; + + auto combo_for_col = [&](const char* label, std::string& target, + const std::vector& options) { + const char* preview = target.empty() ? "(auto)" : target.c_str(); + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo(label, preview)) { + if (ImGui::Selectable("(auto)", target.empty())) target.clear(); + for (auto& o : options) { + bool sel = (target == o); + if (ImGui::Selectable(o, sel)) target = o; + } + ImGui::EndCombo(); + } + }; + + // X col: scatter, line, area, stairs, hist2d, bubble + bool needs_x = (m == ViewMode::Scatter || m == ViewMode::Line || + m == ViewMode::Area || m == ViewMode::Stairs || + m == ViewMode::Histogram2D || m == ViewMode::Bubble); + if (needs_x) combo_for_col("X column", vc.x_col, num_names); + + // Y cols: most modes + bool needs_y = (m != ViewMode::Pie && m != ViewMode::Donut && m != ViewMode::Funnel && + m != ViewMode::Candlestick); + if (needs_y) { + ImGui::Text("Y columns:"); + ImGui::SameLine(); + ImGui::TextDisabled("(%d selected; empty = auto)", (int)vc.y_cols.size()); + ImGui::Indent(); + // Show checkbox for each numeric col + for (auto& nn : num_names) { + std::string ns = nn; + bool checked = std::find(vc.y_cols.begin(), vc.y_cols.end(), ns) != vc.y_cols.end(); + if (ImGui::Checkbox(nn, &checked)) { + if (checked) vc.y_cols.push_back(ns); + else { + auto it = std::find(vc.y_cols.begin(), vc.y_cols.end(), ns); + if (it != vc.y_cols.end()) vc.y_cols.erase(it); + } + } + } + ImGui::Unindent(); + if (ImGui::SmallButton("Clear Y##clr_y")) vc.y_cols.clear(); + } + + // Cat col: bar/pie/funnel/box/waterfall + bool needs_cat = (m == ViewMode::Bar || m == ViewMode::Column || + m == ViewMode::GroupedBar || m == ViewMode::StackedBar || + m == ViewMode::Pie || m == ViewMode::Donut || + m == ViewMode::Funnel || m == ViewMode::BoxPlot || + m == ViewMode::Waterfall); + if (needs_cat) { + // Si el active stage YA esta agrupado (breakouts != empty), la categoria + // del chart la dicta el breakout. Mostrar todas las cols del INPUT del + // stage (= cols pre-agrupacion). Selecionar otra = reemplaza breakouts[0] + // (re-agrupa). + int as = st.active_stage; + bool grouped = (as >= 0 && as < (int)st.stages.size() && + !st.stages[as].breakouts.empty()); + const auto& U = ui(); + if (grouped) { + std::vector input_cat_names; + for (size_t i = 0; i < U.input_headers_active.size() && + i < U.input_types_active.size(); ++i) { + ColumnType t = U.input_types_active[i]; + if (t == ColumnType::String || t == ColumnType::Date || + t == ColumnType::Bool || t == ColumnType::Json) { + input_cat_names.push_back(U.input_headers_active[i].c_str()); + } + } + std::string cur_break = st.stages[as].breakouts[0]; + const char* preview = cur_break.empty() ? "(none)" : cur_break.c_str(); + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("Category (breakout)", preview)) { + for (auto& o : input_cat_names) { + bool sel = (cur_break == o); + if (ImGui::Selectable(o, sel)) { + st.stages[as].breakouts[0] = o; + } + } + ImGui::EndCombo(); + } + } else { + combo_for_col("Category", vc.cat_col, cat_names); + } + } + + // Size col: bubble + if (m == ViewMode::Bubble) combo_for_col("Size column", vc.size_col, num_names); + + // Color + ImGui::Separator(); + float col_f[4] = { + ((vc.primary_color) & 0xFF) / 255.0f, + ((vc.primary_color >> 8) & 0xFF) / 255.0f, + ((vc.primary_color >> 16) & 0xFF) / 255.0f, + ((vc.primary_color >> 24) & 0xFF) / 255.0f, + }; + if (vc.primary_color == 0) { col_f[0]=col_f[1]=col_f[2]=1.0f; col_f[3]=1.0f; } + if (ImGui::ColorEdit4("Primary color", col_f, ImGuiColorEditFlags_AlphaBar)) { + unsigned int r = (unsigned int)(col_f[0] * 255); + unsigned int g = (unsigned int)(col_f[1] * 255); + unsigned int b = (unsigned int)(col_f[2] * 255); + unsigned int a = (unsigned int)(col_f[3] * 255); + vc.primary_color = (a << 24) | (b << 16) | (g << 8) | r; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Auto##color")) vc.primary_color = 0; + + // Hist bins + if (m == ViewMode::Histogram || m == ViewMode::Histogram2D) { + ImGui::SetNextItemWidth(120); + int b = vc.hist_bins; + if (ImGui::InputInt("Bins (0=auto)", &b)) { + if (b < 0) b = 0; + vc.hist_bins = b; + } + } + + // Pie radius + if (m == ViewMode::Pie || m == ViewMode::Donut) { + ImGui::SetNextItemWidth(120); + float r = vc.pie_radius; + if (ImGui::SliderFloat("Radius (0=auto)", &r, 0.0f, 0.5f, "%.2f")) { + vc.pie_radius = r; + } + } + + // Toggles + ImGui::Separator(); + ImGui::Checkbox("Show legend", &vc.show_legend); + if (m == ViewMode::Line || m == ViewMode::Area || m == ViewMode::Stairs) { + ImGui::SameLine(); + ImGui::Checkbox("Show markers", &vc.show_markers); + } + + ImGui::Separator(); + if (ImGui::SmallButton("Reset config")) { + vc = ViewConfig{}; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Close")) ImGui::CloseCurrentPopup(); + + ImGui::EndPopup(); +} + +// Devuelve nombres + tipos del active stage output snapshot (poblado por render). +std::vector collect_active_col_info(const State& st) { + (void)st; + auto& U = ui(); + std::vector r; + int n = (int)std::min(U.active_headers.size(), U.active_types.size()); + r.reserve(n); + for (int i = 0; i < n; ++i) r.push_back({U.active_headers[i], U.active_types[i]}); + return r; +} + +void draw_viz_selector(State& st) { + int n_modes = 0; + const ViewMode* modes = all_view_modes(&n_modes); + + // Right-align: reserve "View: [combo] [Fit] [Lock] [Config] [+ Viz]" + const float combo_w = 200.0f; + const float total_w = combo_w + 50.0f + 280.0f; + float right_edge = ImGui::GetWindowContentRegionMax().x; + float target_x = right_edge - total_w; + float min_x = ImGui::GetCursorPosX() + 20.0f; // do not overlap breadcrumb + if (target_x < min_x) target_x = min_x; + ImGui::SameLine(); + ImGui::SetCursorPosX(target_x); + + ImGui::TextDisabled("View:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(combo_w); + if (ImGui::BeginCombo("##viz_mode", view_mode_label(st.display))) { + for (int i = 0; i < n_modes; ++i) { + bool sel = (modes[i] == st.display); + if (ImGui::Selectable(view_mode_label(modes[i]), sel)) { + ViewMode nm = modes[i]; + if (nm != st.display) { + st.display = nm; + if (view_mode_needs_aggregation(nm)) { + auto_promote_aggregated(st); + } + } + } + if (sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Fit##viz_fit")) { + st.viz_config.fit_request = true; + } + ImGui::SameLine(); + bool locked = st.viz_config.locked; + if (locked) { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(180, 60, 60, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(200, 80, 80, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(150, 40, 40, 240)); + } + if (ImGui::SmallButton(locked ? "Locked##viz_lock" : "Lock##viz_lock")) { + st.viz_config.locked = !st.viz_config.locked; + } + if (locked) ImGui::PopStyleColor(3); + ImGui::SameLine(); + if (ImGui::SmallButton("Config##viz_cfg")) { + ImGui::OpenPopup("##viz_cfg_popup"); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Ask AI##ask_open")) { + auto& U2 = ui(); + U2.ask_open = true; + U2.ask_busy = false; + U2.ask_error.clear(); + U2.ask_status.clear(); + U2.ask_response_code.clear(); + U2.ask_response_raw.clear(); + U2.ask_current_tql = tql::emit(st, + std::vector(), // emit headers stage 0 (caller fill si necesario) + std::vector()); + } + ImGui::SameLine(); + if (ImGui::SmallButton("+ Viz##viz_add")) { + VizPanel p; + p.display = ViewMode::Bar; + if (view_mode_needs_aggregation(p.display)) { + auto_promote_aggregated(st); + } + st.extra_panels.push_back(p); + } + draw_viz_config_popup(st); + ImGui::NewLine(); +} + +// --------------------------------------------------------------------------- +// Join chips (fase 9 — solo visible si hay joinables). +// --------------------------------------------------------------------------- +void draw_joins_chips(State& st, const std::vector& joinables, + const std::vector& main_headers) { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 80, 130, 90, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(100, 160, 110, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 60, 110, 70, 220)); + ImGui::TextDisabled("Joins:"); + ImGui::SameLine(); + + int remove_idx = -1; + for (size_t i = 0; i < st.joins.size(); ++i) { + const auto& jn = st.joins[i]; + char lbl[256]; + std::string on_str; + for (size_t k = 0; k < jn.on.size(); ++k) { + if (k) on_str += ","; + on_str += jn.on[k].first + "=" + jn.on[k].second; + } + std::snprintf(lbl, sizeof(lbl), "%s <- %s on %s (%s)##join_%zu", + jn.alias.empty() ? "_" : jn.alias.c_str(), + jn.source.c_str(), + on_str.c_str(), + join_strategy_label(jn.strategy), + i); + ImGui::Button(lbl); + ImGui::SameLine(); + char xlbl[32]; std::snprintf(xlbl, sizeof(xlbl), "x##rm_join_%zu", i); + if (ImGui::SmallButton(xlbl)) remove_idx = (int)i; + ImGui::SameLine(); + } + if (remove_idx >= 0) st.joins.erase(st.joins.begin() + remove_idx); + + if (ImGui::SmallButton("+##add_join")) { + ImGui::OpenPopup("##add_join_popup"); + } + ImGui::PopStyleColor(3); + + // Popup add + static int pick_source_idx = 0; + static char pick_alias[64] = ""; + static int pick_strategy = 0; + static int pick_left_col = 0; + static int pick_right_col = 0; + if (ImGui::BeginPopup("##add_join_popup")) { + ImGui::Text("Add join"); + ImGui::SetNextItemWidth(180); + if (ImGui::BeginCombo("source", joinables[pick_source_idx].name.c_str())) { + for (int k = 0; k < (int)joinables.size(); ++k) { + bool sel = (k == pick_source_idx); + if (ImGui::Selectable(joinables[k].name.c_str(), sel)) { + pick_source_idx = k; + pick_right_col = 0; + if (pick_alias[0] == 0) + std::snprintf(pick_alias, sizeof(pick_alias), "%s", joinables[k].name.c_str()); + } + } + ImGui::EndCombo(); + } + ImGui::SetNextItemWidth(180); + ImGui::InputText("alias", pick_alias, sizeof(pick_alias)); + + const char* strategies[] = {"left", "inner", "right", "full"}; + ImGui::SetNextItemWidth(120); + ImGui::Combo("strategy", &pick_strategy, strategies, IM_ARRAYSIZE(strategies)); + + // left col combo (de main_headers) + ImGui::SetNextItemWidth(180); + const char* lcur = (pick_left_col >= 0 && pick_left_col < (int)main_headers.size()) + ? main_headers[pick_left_col].c_str() : "?"; + if (ImGui::BeginCombo("left col", lcur)) { + for (int k = 0; k < (int)main_headers.size(); ++k) { + bool sel = (k == pick_left_col); + if (ImGui::Selectable(main_headers[k].c_str(), sel)) pick_left_col = k; + } + ImGui::EndCombo(); + } + + // right col combo (de joinables[pick_source_idx].headers) + const TableInput& src = joinables[pick_source_idx]; + const char* rcur = (pick_right_col >= 0 && pick_right_col < (int)src.headers.size()) + ? src.headers[pick_right_col].c_str() : "?"; + ImGui::SetNextItemWidth(180); + if (ImGui::BeginCombo("right col", rcur)) { + for (int k = 0; k < (int)src.headers.size(); ++k) { + bool sel = (k == pick_right_col); + if (ImGui::Selectable(src.headers[k].c_str(), sel)) pick_right_col = k; + } + ImGui::EndCombo(); + } + + ImGui::Separator(); + if (ImGui::SmallButton("Add")) { + Join jn; + jn.alias = pick_alias; + jn.source = src.name; + jn.on.push_back({main_headers[pick_left_col], src.headers[pick_right_col]}); + jn.strategy = (JoinStrategy)pick_strategy; + st.joins.push_back(jn); + pick_alias[0] = 0; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Cancel")) ImGui::CloseCurrentPopup(); + + ImGui::EndPopup(); + } + ImGui::NewLine(); +} + +// --------------------------------------------------------------------------- +// Filter chips para el stage activo. eff_headers/eff_cols son del INPUT del +// stage activo (= orig+derived para stage 0; output del stage previo para 1+). +// --------------------------------------------------------------------------- +void draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols, + const std::vector& eff_types) { + auto& U = ui(); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(120, 60, 170, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(150, 85, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 95, 45, 140, 240)); + if (ImGui::SmallButton("+##addfilter_btn")) ImGui::OpenPopup("##addfilter"); + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + // Presets (fase 10): menu con Last7/30/90d (cols Date), ExcludeNulls (any), + // NonZero (cols numericas). Apply append a stg.filters via build_preset_filters. + if (ImGui::SmallButton("Presets##fpresets")) ImGui::OpenPopup("##presets_menu"); + if (ImGui::BeginPopup("##presets_menu")) { + int first_date = -1, first_num = -1; + for (int c = 0; c < eff_cols && c < (int)eff_types.size(); ++c) { + if (first_date < 0 && eff_types[c] == ColumnType::Date) first_date = c; + if (first_num < 0 && (eff_types[c] == ColumnType::Int || + eff_types[c] == ColumnType::Float)) first_num = c; + } + auto apply_preset = [&](FilterPreset p, int col) { + auto fs = build_preset_filters(p, col, today_iso()); + for (auto& f : fs) stg.filters.push_back(f); + }; + if (first_date >= 0) { + char l1[96], l2[96], l3[96]; + std::snprintf(l1, sizeof(l1), "Last 7 days on \"%s\"", eff_headers[first_date]); + std::snprintf(l2, sizeof(l2), "Last 30 days on \"%s\"", eff_headers[first_date]); + std::snprintf(l3, sizeof(l3), "Last 90 days on \"%s\"", eff_headers[first_date]); + if (ImGui::MenuItem(l1)) apply_preset(FilterPreset::Last7d, first_date); + if (ImGui::MenuItem(l2)) apply_preset(FilterPreset::Last30d, first_date); + if (ImGui::MenuItem(l3)) apply_preset(FilterPreset::Last90d, first_date); + ImGui::Separator(); + } + if (ImGui::BeginMenu("Exclude nulls in...")) { + for (int c = 0; c < eff_cols; ++c) { + if (ImGui::MenuItem(eff_headers[c])) apply_preset(FilterPreset::ExcludeNulls, c); + } + ImGui::EndMenu(); + } + if (first_num >= 0) { + if (ImGui::BeginMenu("Non-zero in...")) { + for (int c = 0; c < eff_cols && c < (int)eff_types.size(); ++c) { + if (eff_types[c] == ColumnType::Int || eff_types[c] == ColumnType::Float) { + if (ImGui::MenuItem(eff_headers[c])) apply_preset(FilterPreset::NonZero, c); + } + } + ImGui::EndMenu(); + } + } + ImGui::EndPopup(); + } + ImGui::SameLine(); + + if (stg.filters.empty()) { + ImGui::TextDisabled("Sin filtros."); + return; + } + for (size_t i = 0; i < stg.filters.size(); ) { + const auto& f = stg.filters[i]; + const char* hdr = (f.col >= 0 && f.col < eff_cols) ? eff_headers[f.col] : "?"; + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s %s %s x##chip%zu", + hdr, op_label(f.op), f.value.c_str(), i); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(120, 60, 170, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(150, 85, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 95, 45, 140, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + // Click derecho: edit + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.edit_chip_kind = 1; + U.edit_chip_idx = (int)i; + U.edit_col_idx = f.col; + U.edit_op = (int)f.op; + U.edit_value = f.value; + ImGui::OpenPopup("##edit_filter"); + } + if (clicked) { stg.filters.erase(stg.filters.begin() + i); continue; } + ImGui::SameLine(); + ++i; + } + ImGui::NewLine(); +} + +// Chips de breakout (stage > 0). +void draw_breakout_chips(Stage& stg, const char* const* in_headers, int in_cols, + const std::vector& in_types) { + auto& U = ui(); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 60, 160, 170, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 80, 190, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 40, 130, 140, 240)); + if (ImGui::SmallButton("+##addbreakout_btn")) ImGui::OpenPopup("##addbreakout"); + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + if (stg.breakouts.empty()) { + ImGui::TextDisabled("Group by: ninguna col."); + return; + } + for (size_t i = 0; i < stg.breakouts.size(); ) { + std::string col_name; + DateGranularity g = parse_breakout_granularity(stg.breakouts[i], col_name); + + // Resolve col index para lookup de tipo. + int col_idx = -1; + for (int c = 0; c < in_cols; ++c) { + if (std::strcmp(in_headers[c], col_name.c_str()) == 0) { col_idx = c; break; } + } + bool is_date_col = (col_idx >= 0 && col_idx < (int)in_types.size() + && in_types[col_idx] == ColumnType::Date); + + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s x##bk%zu", stg.breakouts[i].c_str(), i); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 60, 160, 170, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 80, 190, 200, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 40, 130, 140, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.edit_chip_kind = 2; + U.edit_chip_idx = (int)i; + U.edit_col_idx = (col_idx >= 0) ? col_idx : 0; + ImGui::OpenPopup("##edit_breakout"); + } + if (clicked) { stg.breakouts.erase(stg.breakouts.begin() + i); continue; } + + // Granularity combo inline cuando col Date (fase 10). + if (is_date_col) { + ImGui::SameLine(); + const char* preview = (g == DateGranularity::None) + ? "(raw)" : date_granularity_token(g); + char combo_id[32]; + std::snprintf(combo_id, sizeof(combo_id), "##gran%zu", i); + ImGui::SetNextItemWidth(72); + if (ImGui::BeginCombo(combo_id, preview)) { + DateGranularity opts[] = { + DateGranularity::None, + DateGranularity::Year, + DateGranularity::Month, + DateGranularity::Week, + DateGranularity::Day, + DateGranularity::Hour, + }; + for (auto o : opts) { + const char* lbl = (o == DateGranularity::None) + ? "(raw)" : date_granularity_token(o); + if (ImGui::Selectable(lbl, o == g)) { + stg.breakouts[i] = compose_breakout(col_name, o); + } + } + ImGui::EndCombo(); + } + } + + ImGui::SameLine(); + ++i; + } + ImGui::NewLine(); +} + +const char* agg_fn_label(AggFn f) { + switch (f) { + case AggFn::Count: return "count"; + case AggFn::Sum: return "sum"; + case AggFn::Avg: return "avg"; + case AggFn::Min: return "min"; + case AggFn::Max: return "max"; + case AggFn::Distinct: return "distinct"; + case AggFn::Stddev: return "stddev"; + case AggFn::Median: return "median"; + case AggFn::P25: return "p25"; + case AggFn::P75: return "p75"; + case AggFn::P90: return "p90"; + case AggFn::P99: return "p99"; + case AggFn::Percentile: return "percentile"; + } + return "?"; +} + +void draw_aggregation_chips(Stage& stg, const char* const* in_headers, int in_cols) { + auto& U = ui(); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 40, 140, 60, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 60, 170, 85, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 30, 110, 45, 240)); + if (ImGui::SmallButton("+##addagg_btn")) ImGui::OpenPopup("##addagg"); + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + if (stg.aggregations.empty()) { + ImGui::TextDisabled("Aggregations: ninguna."); + return; + } + for (size_t i = 0; i < stg.aggregations.size(); ) { + const auto& a = stg.aggregations[i]; + char buf[256]; + if (a.fn == AggFn::Count) { + std::snprintf(buf, sizeof(buf), "count x##ag%zu", i); + } else if (a.fn == AggFn::Percentile) { + std::snprintf(buf, sizeof(buf), "percentile(%s, %g) x##ag%zu", + a.col.c_str(), a.arg, i); + } else { + std::snprintf(buf, sizeof(buf), "%s(%s) x##ag%zu", + agg_fn_label(a.fn), a.col.c_str(), i); + } + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 40, 140, 60, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 60, 170, 85, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 30, 110, 45, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.edit_chip_kind = 3; + U.edit_chip_idx = (int)i; + U.edit_agg_fn = (int)a.fn; + U.edit_agg_arg = a.arg; + U.edit_col_idx = 0; + for (int c = 0; c < in_cols; ++c) { + if (std::strcmp(in_headers[c], a.col.c_str()) == 0) { + U.edit_col_idx = c; break; + } + } + ImGui::OpenPopup("##edit_agg"); + } + if (clicked) { stg.aggregations.erase(stg.aggregations.begin() + i); continue; } + ImGui::SameLine(); + ++i; + } + (void)in_headers; (void)in_cols; + ImGui::NewLine(); +} + +void draw_sort_chips(Stage& stg) { + auto& U = ui(); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(220, 130, 50, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(240, 155, 75, 245)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(180, 100, 30, 240)); + if (ImGui::SmallButton("+##addsort_btn")) ImGui::OpenPopup("##addsort"); + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + if (stg.sorts.empty()) { + ImGui::TextDisabled("Sort: ninguno."); + return; + } + int erase_idx = -1; + int drag_src = -1; + int drag_dst = -1; + for (size_t i = 0; i < stg.sorts.size(); ++i) { + const auto& sc = stg.sorts[i]; + char buf[256]; + std::snprintf(buf, sizeof(buf), "%zu. %s %s x##srt%zu", + i + 1, sc.col.c_str(), sc.desc ? "desc" : "asc", i); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(220, 130, 50, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(240, 155, 75, 245)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(180, 100, 30, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + + // Drag source: prioridad multi-sort reorderable. + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + int idx = (int)i; + ImGui::SetDragDropPayload("##sortreorder", &idx, sizeof(int)); + ImGui::Text("Move sort #%zu", i + 1); + ImGui::EndDragDropSource(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("##sortreorder")) { + drag_src = *(const int*)p->Data; + drag_dst = (int)i; + } + ImGui::EndDragDropTarget(); + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.edit_chip_kind = 4; + U.edit_chip_idx = (int)i; + U.edit_value = sc.col; + U.edit_sort_desc = sc.desc; + ImGui::OpenPopup("##edit_sort"); + } + if (clicked) erase_idx = (int)i; + ImGui::SameLine(); + } + ImGui::NewLine(); + + if (drag_src >= 0 && drag_dst >= 0 && drag_src != drag_dst && + drag_src < (int)stg.sorts.size() && drag_dst < (int)stg.sorts.size()) + { + SortClause moved = std::move(stg.sorts[drag_src]); + stg.sorts.erase(stg.sorts.begin() + drag_src); + int insert_at = (drag_src < drag_dst) ? drag_dst : drag_dst; + if (insert_at > (int)stg.sorts.size()) insert_at = (int)stg.sorts.size(); + stg.sorts.insert(stg.sorts.begin() + insert_at, std::move(moved)); + } else if (erase_idx >= 0 && erase_idx < (int)stg.sorts.size()) { + stg.sorts.erase(stg.sorts.begin() + erase_idx); + } +} + +// ---- Edit chip popups: click derecho sobre chip abre popup. ---- +// Header click handler: +// click: si col ya esta en sorts -> cicla su direccion asc/desc/off. +// sino -> append {col, asc} al final (multi-sort por defecto). +// shift+click: reset. Reemplaza sorts con {col, asc} (sort unico). +void apply_header_sort_click(Stage& stg, const std::string& col_name, bool shift) { + if (shift) { + stg.sorts.clear(); + stg.sorts.push_back({col_name, false}); + return; + } + int idx = -1; + for (size_t i = 0; i < stg.sorts.size(); ++i) { + if (stg.sorts[i].col == col_name) { idx = (int)i; break; } + } + if (idx < 0) { + stg.sorts.push_back({col_name, false}); + } else { + if (!stg.sorts[idx].desc) stg.sorts[idx].desc = true; + else stg.sorts.erase(stg.sorts.begin() + idx); + } +} + +void draw_edit_filter_popup(Stage& stg, const char* const* headers, int n_cols, + const std::vector& types) { + auto& U = ui(); + if (!ImGui::BeginPopup("##edit_filter")) return; + if (U.edit_chip_idx >= 0 && U.edit_chip_idx < (int)stg.filters.size()) { + auto& f = stg.filters[U.edit_chip_idx]; + ImGui::SetNextItemWidth(200); + const char* cur = (U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + ? headers[U.edit_col_idx] : "?"; + if (ImGui::BeginCombo("col", cur)) { + for (int c = 0; c < n_cols; ++c) { + bool sel = (U.edit_col_idx == c); + if (ImGui::Selectable(headers[c], sel)) U.edit_col_idx = c; + } + ImGui::EndCombo(); + } + ColumnType t = (U.edit_col_idx >= 0 && U.edit_col_idx < (int)types.size()) + ? types[U.edit_col_idx] : ColumnType::String; + auto ops = ops_for_type(t); + ImGui::SetNextItemWidth(140); + if (ImGui::BeginCombo("op", op_label((Op)U.edit_op))) { + for (auto o : ops) { + bool sel = ((int)o == U.edit_op); + if (ImGui::Selectable(op_label(o), sel)) U.edit_op = (int)o; + } + ImGui::EndCombo(); + } + char vbuf[256] = {0}; + std::snprintf(vbuf, sizeof(vbuf), "%s", U.edit_value.c_str()); + ImGui::SetNextItemWidth(220); + if (ImGui::InputText("value", vbuf, sizeof(vbuf))) U.edit_value = vbuf; + if (ImGui::Button("Save")) { + f.col = U.edit_col_idx; + f.op = (Op)U.edit_op; + f.value = U.edit_value; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_edit_breakout_popup(Stage& stg, const char* const* headers, int n_cols) { + auto& U = ui(); + if (!ImGui::BeginPopup("##edit_breakout")) return; + if (U.edit_chip_idx >= 0 && U.edit_chip_idx < (int)stg.breakouts.size()) { + ImGui::SetNextItemWidth(240); + const char* cur = (U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + ? headers[U.edit_col_idx] : "?"; + if (ImGui::BeginCombo("col", cur)) { + for (int c = 0; c < n_cols; ++c) { + bool sel = (U.edit_col_idx == c); + if (ImGui::Selectable(headers[c], sel)) U.edit_col_idx = c; + } + ImGui::EndCombo(); + } + if (ImGui::Button("Save")) { + if (U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + stg.breakouts[U.edit_chip_idx] = headers[U.edit_col_idx]; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_edit_agg_popup(Stage& stg, const char* const* headers, int n_cols) { + auto& U = ui(); + if (!ImGui::BeginPopup("##edit_agg")) return; + if (U.edit_chip_idx >= 0 && U.edit_chip_idx < (int)stg.aggregations.size()) { + const AggFn all_fns[] = {AggFn::Count, AggFn::Sum, AggFn::Avg, AggFn::Min, AggFn::Max, + AggFn::Distinct, AggFn::Stddev, AggFn::Median, + AggFn::P25, AggFn::P75, AggFn::P90, AggFn::P99, + AggFn::Percentile}; + ImGui::SetNextItemWidth(160); + if (ImGui::BeginCombo("fn", agg_fn_label((AggFn)U.edit_agg_fn))) { + for (auto f : all_fns) { + bool sel = ((int)f == U.edit_agg_fn); + if (ImGui::Selectable(agg_fn_label(f), sel)) U.edit_agg_fn = (int)f; + } + ImGui::EndCombo(); + } + if ((AggFn)U.edit_agg_fn != AggFn::Count) { + ImGui::SetNextItemWidth(200); + const char* cur = (U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + ? headers[U.edit_col_idx] : "?"; + if (ImGui::BeginCombo("col", cur)) { + for (int c = 0; c < n_cols; ++c) { + bool sel = (U.edit_col_idx == c); + if (ImGui::Selectable(headers[c], sel)) U.edit_col_idx = c; + } + ImGui::EndCombo(); + } + } + if ((AggFn)U.edit_agg_fn == AggFn::Percentile) { + float v = (float)U.edit_agg_arg; + ImGui::SetNextItemWidth(140); + if (ImGui::InputFloat("p (0..1)", &v, 0.05f, 0.1f, "%.2f")) + U.edit_agg_arg = v; + } + if (ImGui::Button("Save")) { + auto& a = stg.aggregations[U.edit_chip_idx]; + a.fn = (AggFn)U.edit_agg_fn; + if (a.fn != AggFn::Count && U.edit_col_idx >= 0 && U.edit_col_idx < n_cols) + a.col = headers[U.edit_col_idx]; + else if (a.fn == AggFn::Count) a.col.clear(); + a.arg = U.edit_agg_arg; + a.alias.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_edit_sort_popup(Stage& stg, const char* const* headers, int n_cols) { + auto& U = ui(); + if (!ImGui::BeginPopup("##edit_sort")) return; + if (U.edit_chip_idx >= 0 && U.edit_chip_idx < (int)stg.sorts.size()) { + ImGui::SetNextItemWidth(240); + if (ImGui::BeginCombo("col", U.edit_value.c_str())) { + for (int c = 0; c < n_cols; ++c) { + bool sel = (U.edit_value == headers[c]); + if (ImGui::Selectable(headers[c], sel)) U.edit_value = headers[c]; + } + ImGui::EndCombo(); + } + if (ImGui::RadioButton("asc", !U.edit_sort_desc)) U.edit_sort_desc = false; + ImGui::SameLine(); + if (ImGui::RadioButton("desc", U.edit_sort_desc)) U.edit_sort_desc = true; + if (ImGui::Button("Save")) { + auto& sc = stg.sorts[U.edit_chip_idx]; + sc.col = U.edit_value; + sc.desc = U.edit_sort_desc; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void maybe_recompute_stats(const char* const* cells, int rows, int orig_cols, + int eff_cols, const std::vector& filters, + const std::vector& visible, + const std::vector& src_for_eff) +{ + auto& U = ui(); + if (!U.stats_mode) return; + size_t fh = filters_hash(filters); + bool ds_changed = (cells != U.last_cells || rows != U.last_rows || + eff_cols != U.last_eff_cols || + (int)U.stats_cache.size() != eff_cols); + bool fl_changed = (fh != U.last_filter_h || (int)visible.size() != U.last_visible); + if (!ds_changed && !fl_changed) return; + U.stats_cache.resize(eff_cols); + const int* idx = visible.empty() ? nullptr : visible.data(); + int n = (int)visible.size(); + for (int c = 0; c < eff_cols; ++c) { + int src = src_for_eff[c]; + U.stats_cache[c] = compute_column_stats(cells, rows, orig_cols, src, + 100000, idx, n); + } + U.last_cells = cells; + U.last_rows = rows; + U.last_eff_cols = eff_cols; + U.last_filter_h = fh; + U.last_visible = (int)visible.size(); +} + +bool draw_typed_ops(ColumnType t, Op& out) { + auto ops = ops_for_type(t); + for (size_t i = 0; i < ops.size(); ++i) { + if (i % 5 != 0) ImGui::SameLine(); + if (ImGui::SmallButton(op_label(ops[i]))) { out = ops[i]; return true; } + } + return false; +} + +bool type_supports_range(ColumnType t) { + return t == ColumnType::Int || t == ColumnType::Float || t == ColumnType::Date; +} + +void draw_add_filter_popup(Stage& stg, const char* const* eff_headers_arr, int eff_cols, + const std::vector& eff_types) +{ + auto& U = ui(); + if (!ImGui::BeginPopup("##addfilter")) return; + if (U.addf_col < 0 || U.addf_col >= eff_cols) U.addf_col = 0; + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("col", eff_headers_arr[U.addf_col])) { + for (int c = 0; c < eff_cols; ++c) { + char it[160]; + std::snprintf(it, sizeof(it), "%s %s", + column_type_icon(eff_types[c]), eff_headers_arr[c]); + bool sel = (U.addf_col == c); + if (ImGui::Selectable(it, sel)) U.addf_col = c; + } + ImGui::EndCombo(); + } + ColumnType t = eff_types[U.addf_col]; + ImGui::TextDisabled("type: %s %s", column_type_icon(t), column_type_name(t)); + + bool can_range = type_supports_range(t); + if (can_range) ImGui::Checkbox("Range (min/max)", &U.addf_range); + else U.addf_range = false; + + if (!U.addf_range) { + char buf[256] = {0}; + std::snprintf(buf, sizeof(buf), "%s", U.addf_val.c_str()); + ImGui::SetNextItemWidth(220); + if (ImGui::InputText("val", buf, sizeof(buf))) U.addf_val = buf; + Op picked; + if (draw_typed_ops(t, picked)) { + stg.filters.push_back({U.addf_col, picked, U.addf_val}); + U.addf_val.clear(); + ImGui::CloseCurrentPopup(); + } + } else { + char lo[128] = {0}, hi[128] = {0}; + std::snprintf(lo, sizeof(lo), "%s", U.addf_lo.c_str()); + std::snprintf(hi, sizeof(hi), "%s", U.addf_hi.c_str()); + ImGui::SetNextItemWidth(100); + if (ImGui::InputText("min", lo, sizeof(lo))) U.addf_lo = lo; + ImGui::SameLine(); + ImGui::SetNextItemWidth(100); + if (ImGui::InputText("max", hi, sizeof(hi))) U.addf_hi = hi; + ImGui::SameLine(); + if (ImGui::SmallButton("Add range")) { + if (!U.addf_lo.empty()) stg.filters.push_back({U.addf_col, Op::Gte, U.addf_lo}); + if (!U.addf_hi.empty()) stg.filters.push_back({U.addf_col, Op::Lte, U.addf_hi}); + U.addf_lo.clear(); U.addf_hi.clear(); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); +} + +void draw_add_breakout_popup(Stage& stg, const char* const* in_headers, int in_cols, + const std::vector& in_types, + const char* const* in_cells, int in_rows) { + auto& U = ui(); + if (!ImGui::BeginPopup("##addbreakout")) return; + if (U.brk_picker_col < 0 || U.brk_picker_col >= in_cols) U.brk_picker_col = 0; + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("col##bkcol", in_headers[U.brk_picker_col])) { + for (int c = 0; c < in_cols; ++c) { + char it[160]; + std::snprintf(it, sizeof(it), "%s %s", + column_type_icon(in_types[c]), in_headers[c]); + bool sel = (U.brk_picker_col == c); + if (ImGui::Selectable(it, sel)) U.brk_picker_col = c; + } + ImGui::EndCombo(); + } + if (ImGui::Button("Add##bk")) { + int c = U.brk_picker_col; + std::string col = in_headers[c]; + // Fase 10: si col es Date, auto-detect granularidad via rango lexical + // (ISO YYYY-MM-DD ordena bien). Default Day si rango invalido. + if (c >= 0 && c < (int)in_types.size() && in_types[c] == ColumnType::Date) { + std::string lo, hi; + column_min_max(in_cells, in_rows, in_cols, c, lo, hi); + DateGranularity g = auto_date_granularity(lo, hi); + stg.breakouts.emplace_back(compose_breakout(col, g)); + } else { + stg.breakouts.emplace_back(col); + } + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_add_aggregation_popup(Stage& stg, const char* const* in_headers, int in_cols, + const std::vector& in_types) { + auto& U = ui(); + if (!ImGui::BeginPopup("##addagg")) return; + + AggFn cur_fn = (AggFn)U.agg_picker_fn; + ImGui::SetNextItemWidth(160); + if (ImGui::BeginCombo("fn##aggfn", agg_fn_label(cur_fn))) { + AggFn all[] = {AggFn::Count, AggFn::Sum, AggFn::Avg, AggFn::Min, AggFn::Max, + AggFn::Distinct, AggFn::Stddev, AggFn::Median, + AggFn::P25, AggFn::P75, AggFn::P90, AggFn::P99, + AggFn::Percentile}; + for (AggFn f : all) { + bool sel = (f == cur_fn); + if (ImGui::Selectable(agg_fn_label(f), sel)) U.agg_picker_fn = (int)f; + } + ImGui::EndCombo(); + } + if (cur_fn != AggFn::Count) { + if (U.agg_picker_col < 0 || U.agg_picker_col >= in_cols) U.agg_picker_col = 0; + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("col##aggcol", in_headers[U.agg_picker_col])) { + for (int c = 0; c < in_cols; ++c) { + char it[160]; + std::snprintf(it, sizeof(it), "%s %s", + column_type_icon(in_types[c]), in_headers[c]); + bool sel = (U.agg_picker_col == c); + if (ImGui::Selectable(it, sel)) U.agg_picker_col = c; + } + ImGui::EndCombo(); + } + } + if (cur_fn == AggFn::Percentile) { + double v = U.agg_picker_arg; + ImGui::SetNextItemWidth(120); + if (ImGui::InputDouble("p (0..1)", &v, 0.05, 0.1, "%.2f")) { + if (v < 0) v = 0; if (v > 1) v = 1; + U.agg_picker_arg = v; + } + } + if (ImGui::Button("Add##ag")) { + Aggregation a; + a.fn = cur_fn; + a.col = (cur_fn == AggFn::Count) ? "" : std::string(in_headers[U.agg_picker_col]); + a.arg = (cur_fn == AggFn::Percentile) ? U.agg_picker_arg : 0.0; + stg.aggregations.push_back(a); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_add_sort_popup(Stage& stg, const char* const* in_headers, int in_cols, + const std::vector& in_types) { + auto& U = ui(); + if (!ImGui::BeginPopup("##addsort")) return; + if (U.sort_picker_col < 0 || U.sort_picker_col >= in_cols) U.sort_picker_col = 0; + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("col##sortcol", in_headers[U.sort_picker_col])) { + for (int c = 0; c < in_cols; ++c) { + char it[160]; + std::snprintf(it, sizeof(it), "%s %s", + column_type_icon(in_types[c]), in_headers[c]); + bool sel = (U.sort_picker_col == c); + if (ImGui::Selectable(it, sel)) U.sort_picker_col = c; + } + ImGui::EndCombo(); + } + ImGui::Checkbox("desc", &U.sort_picker_desc); + if (ImGui::Button("Add##srt")) { + SortClause sc; + sc.col = in_headers[U.sort_picker_col]; + sc.desc = U.sort_picker_desc; + stg.sorts.push_back(sc); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_header_menu(State& st, Stage& stg, int col, + const char* const* eff_headers_arr, int eff_cols, + const std::vector& eff_types, + int orig_cols, bool is_raw_stage) +{ + auto& U = ui(); + ColumnType t = eff_types[col]; + + if (ImGui::MenuItem("Sort ascending")) { + stg.sorts.clear(); + stg.sorts.push_back({eff_headers_arr[col], false}); + } + if (ImGui::MenuItem("Sort descending")) { + stg.sorts.clear(); + stg.sorts.push_back({eff_headers_arr[col], true}); + } + if (!stg.sorts.empty() && ImGui::MenuItem("Clear sort")) stg.sorts.clear(); + ImGui::Separator(); + + auto& fbuf = U.filter_inputs[col]; + fbuf.resize(256, '\0'); + if (ImGui::BeginMenu("Filter...")) { + ImGui::SetNextItemWidth(220); + ImGui::InputText("##filterval", fbuf.data(), fbuf.size()); + std::string val(fbuf.c_str()); + auto ops = ops_for_type(t); + for (size_t i = 0; i < ops.size(); ++i) { + if (i % 5 != 0) ImGui::SameLine(); + if (ImGui::SmallButton(op_label(ops[i]))) { + stg.filters.push_back({col, ops[i], val}); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndMenu(); + } + + // Change type / derived solo en stage 0. + if (is_raw_stage) { + if (ImGui::BeginMenu("Change type")) { + const ColumnType types[] = { + ColumnType::String, ColumnType::Int, ColumnType::Float, + ColumnType::Bool, ColumnType::Date, ColumnType::Json + }; + for (auto nt : types) { + char lab[64]; + std::snprintf(lab, sizeof(lab), "%s %s", + column_type_icon(nt), column_type_name(nt)); + if (ImGui::MenuItem(lab)) { + DerivedColumn d; + d.source_col = (col < orig_cols) ? col : stg.derived[col - orig_cols].source_col; + d.type = nt; + d.name = std::string(eff_headers_arr[col]) + "_" + column_type_name(nt); + stg.derived.push_back(d); + } + } + ImGui::EndMenu(); + } + } + + if (ImGui::BeginMenu("Conditional color")) { + auto& vbuf = U.color_value_inputs[col]; + vbuf.resize(256, '\0'); + if (U.color_picker_vals.find(col) == U.color_picker_vals.end()) + U.color_picker_vals[col] = ImVec4(0.85f, 0.40f, 0.30f, 0.60f); + ImVec4& cv = U.color_picker_vals[col]; + ImGui::SetNextItemWidth(180); + ImGui::InputText("equals", vbuf.data(), vbuf.size()); + ImGui::ColorEdit4("color", &cv.x, ImGuiColorEditFlags_NoInputs); + if (ImGui::Button("Apply")) { + ImU32 c = ImGui::ColorConvertFloat4ToU32(cv); + st.color_rules.push_back({col, std::string(vbuf.c_str()), (unsigned int)c}); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Clear col")) { + for (size_t i = 0; i < st.color_rules.size();) { + if (st.color_rules[i].col == col) st.color_rules.erase(st.color_rules.begin() + i); + else ++i; + } + } + ImGui::EndMenu(); + } + + if (ImGui::MenuItem("Hide column")) st.col_visible[col] = false; + + if (is_raw_stage && col >= orig_cols && ImGui::MenuItem("Remove derived column")) { + int k = col - orig_cols; + stg.derived.erase(stg.derived.begin() + k); + } + + ImGui::Separator(); + if (ImGui::BeginMenu("Columns")) { + for (int k = 0; k < eff_cols; ++k) { + bool v = st.col_visible[k]; + char lab[160]; + std::snprintf(lab, sizeof(lab), "%s %s", + column_type_icon(eff_types[k]), eff_headers_arr[k]); + if (ImGui::Checkbox(lab, &v)) st.col_visible[k] = v; + } + if (ImGui::MenuItem("Show all")) { + for (int k = 0; k < eff_cols; ++k) st.col_visible[k] = true; + } + ImGui::EndMenu(); + } +} + +// --------------------------------------------------------------------------- +// Drill-down: anade un filter al stage previo y cambia active a stage previo. +// `col_name` y `value` se aplican como un Filter Op::Eq sobre el stage N-1. +// --------------------------------------------------------------------------- +void drill_into(State& st, int from_stage, + const std::string& col_name, const std::string& value, + const std::vector& prev_input_headers) +{ + if (from_stage <= 0 || from_stage >= (int)st.stages.size()) return; + int target = from_stage - 1; + int ci = -1; + for (size_t i = 0; i < prev_input_headers.size(); ++i) { + if (prev_input_headers[i] == col_name) { ci = (int)i; break; } + } + if (ci < 0) return; + + // Fase 10: graba step en drill_back, limpia forward (rama nueva). + DrillStep step; + step.target_stage = target; + step.filter_pos = (int)st.stages[target].filters.size(); + step.prev_active_stage = st.active_stage; + step.added = make_drill_filter(ci, value); + apply_drill_step(st, step); + auto& U = ui(); + U.drill_back.push_back(step); + U.drill_forward.clear(); +} + +} // anon namespace + +void render(const char* id, + const std::vector& tables, + State& st, + bool show_chrome) +{ + if (tables.empty()) return; + int main_idx = resolve_main_idx(tables, st.main_source); + if (main_idx < 0) return; + + // Construir headers ptrs desde main table. + const TableInput& main_t = tables[(size_t)main_idx]; + static thread_local std::vector main_hdr_ptrs; + main_hdr_ptrs.clear(); + main_hdr_ptrs.reserve(main_t.cols); + for (int c = 0; c < main_t.cols; ++c) main_hdr_ptrs.push_back(main_t.headers[c].c_str()); + const char* const* headers_in = main_hdr_ptrs.data(); + int col_count = main_t.cols; + const char* const* cells_in = main_t.cells; + int row_count_in = main_t.rows; + const ColumnType* declared_types_in = main_t.types.data(); + + // Joinables = todas las demas tablas. + static thread_local std::vector joinables_v; + joinables_v.clear(); + for (int i = 0; i < (int)tables.size(); ++i) { + if (i != main_idx) joinables_v.push_back(tables[(size_t)i]); + } + const std::vector* joinables = joinables_v.empty() ? nullptr : &joinables_v; + + auto& U_chrome = ui(); + bool chrome_visible = U_chrome.chrome_user_set ? U_chrome.chrome_user_visible : show_chrome; + + // Toggle Hide/Show UI siempre visible (botoncito arriba a la derecha). + { + float right = ImGui::GetWindowContentRegionMax().x; + ImGui::SetCursorPosX(right - 90.0f); + if (ImGui::SmallButton(chrome_visible ? "Hide UI##chrome" : "Show UI##chrome")) { + U_chrome.chrome_user_set = true; + U_chrome.chrome_user_visible = !chrome_visible; + } + } + + // Main source dropdown — solo si > 1 tabla disponibles. + if (chrome_visible && tables.size() > 1) { + ImGui::SameLine(); + float right = ImGui::GetWindowContentRegionMax().x; + ImGui::SetCursorPosX(right - 90.0f - 280.0f); + ImGui::TextDisabled("Main table:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(180); + const char* cur_main = main_t.name.c_str(); + if (ImGui::BeginCombo("##main_table", cur_main)) { + for (const auto& t : tables) { + bool sel = (t.name == cur_main); + if (ImGui::Selectable(t.name.c_str(), sel)) { + st.main_source = t.name; + } + } + ImGui::EndCombo(); + } + } + + st.ensure_stage0(); + + // -------- Pre-pipeline: materialize joins -------- + // Si state.joins no vacio + joinables provistos, ejecutar chain de join_tables. + // El resultado reemplaza headers/cells/declared_types para el resto del render. + static thread_local std::vector joined_headers_store; + static thread_local std::vector joined_types_store; + static thread_local std::vector joined_headers_ptrs; + static thread_local std::vector joined_cells_ptrs; + static thread_local std::vector joined_declared_types; + static thread_local StageOutput joined_so; + + const char* const* headers = headers_in; + const char* const* cells = cells_in; + int row_count = row_count_in; + int orig_cols = col_count; + const ColumnType* declared_types = declared_types_in; + + bool joined = false; + if (!st.joins.empty() && joinables && !joinables->empty()) { + joined_so = StageOutput{}; + // Build initial left from main. + std::vector cur_h(orig_cols); + std::vector cur_t(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + cur_h[c] = headers_in[c]; + cur_t[c] = declared_types_in ? declared_types_in[c] : ColumnType::Auto; + } + const char* const* cur_cells = cells_in; + int cur_rows = row_count_in; + int cur_cols = orig_cols; + + // Chain join por cada joins[i]. + std::vector chain; + chain.reserve(st.joins.size()); + for (const auto& jn : st.joins) { + const TableInput* match = nullptr; + for (const auto& ti : *joinables) { + if (ti.name == jn.source) { match = &ti; break; } + } + if (!match) continue; + StageOutput so = join_tables(cur_cells, cur_rows, cur_cols, + cur_h, cur_t, *match, jn); + chain.push_back(std::move(so)); + const StageOutput& last = chain.back(); + cur_cells = last.cells.data(); + cur_rows = last.rows; + cur_cols = last.cols; + cur_h = last.headers; + cur_t = last.types; + } + + if (!chain.empty()) { + joined = true; + joined_so = std::move(chain.back()); + joined_headers_store = joined_so.headers; + joined_types_store = joined_so.types; + joined_headers_ptrs.clear(); + joined_cells_ptrs.clear(); + for (const auto& s : joined_headers_store) joined_headers_ptrs.push_back(s.c_str()); + for (const auto& s : joined_so.cell_backing) joined_cells_ptrs.push_back(s.c_str()); + joined_declared_types = joined_types_store; + + headers = joined_headers_ptrs.data(); + cells = joined_cells_ptrs.data(); + row_count = joined_so.rows; + orig_cols = joined_so.cols; + declared_types = joined_declared_types.data(); + } + } + + Stage& stage0 = st.stages[0]; + int eff_cols = orig_cols + (int)stage0.derived.size(); + + ensure_init(st, eff_cols); + auto& U = ui(); + + // Build eff_headers / src_for_eff / eff_types para STAGE 0. + std::vector eff_headers(eff_cols); + std::vector src_for_eff(eff_cols); + std::vector eff_types(eff_cols); + for (int c = 0; c < eff_cols; ++c) { + if (c < orig_cols) { + eff_headers[c] = headers[c]; + src_for_eff[c] = c; + ColumnType d = declared_types ? declared_types[c] : ColumnType::Auto; + eff_types[c] = effective_type(d, cells, row_count, orig_cols, c); + } else { + const DerivedColumn& d = stage0.derived[c - orig_cols]; + eff_headers[c] = d.name.c_str(); + src_for_eff[c] = d.source_col; + eff_types[c] = d.type; + } + } + + static thread_local std::vector hn_storage; + static thread_local std::unordered_map name_to_col; + static thread_local std::unordered_map derived_n2i; + hn_storage.clear(); + name_to_col.clear(); + derived_n2i.clear(); + hn_storage.reserve(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + hn_storage.emplace_back(headers[c]); + name_to_col[hn_storage.back()] = c; + } + for (int i = 0; i < (int)stage0.derived.size(); ++i) { + derived_n2i[stage0.derived[i].name] = i; + } + + // Re-fit auto en cambio de display, stage o config. + auto hash_cfg = [](const ViewConfig& c) -> size_t { + std::string s = c.x_col + "|" + c.cat_col + "|" + c.size_col; + for (auto& y : c.y_cols) { s += "|"; s += y; } + s += "|"; s += std::to_string(c.primary_color); + s += "|"; s += std::to_string(c.hist_bins); + s += "|"; s += std::to_string(c.pie_radius); + s += "|"; s += c.show_legend ? "1" : "0"; + s += "|"; s += c.show_markers ? "1" : "0"; + return std::hash{}(s); + }; + size_t cur_cfg_h = hash_cfg(st.viz_config); + if (U.prev_viz_display != st.display || U.prev_viz_stage != st.active_stage || + U.prev_viz_cfg_h != cur_cfg_h) { + st.viz_config.fit_request = true; + U.prev_viz_display = st.display; + U.prev_viz_stage = st.active_stage; + U.prev_viz_cfg_h = cur_cfg_h; + } + + // ----- Breadcrumb + viz selector (chrome) ----- + if (chrome_visible) { + draw_stage_breadcrumb(st); + draw_viz_selector(st); + } + int active = st.active_stage; + bool is_raw = (active == 0); + + // ----- Chips del stage activo ----- + Stage& act = st.stages[active]; + + if (is_raw && chrome_visible) { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2)); + // Joins chip row — solo si hay joinables disponibles. + if (joinables && !joinables->empty()) { + std::vector mh(orig_cols); + for (int c = 0; c < orig_cols; ++c) mh[c] = headers[c]; + draw_joins_chips(st, *joinables, mh); + } + + draw_filter_chips(act, eff_headers.data(), eff_cols, eff_types); + draw_add_filter_popup(act, eff_headers.data(), eff_cols, eff_types); + draw_edit_filter_popup(act, eff_headers.data(), eff_cols, eff_types); + + // Custom columns chips (solo stage 0) + { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(110, 110, 110, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(140, 140, 140, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 85, 85, 85, 230)); + if (ImGui::SmallButton("+##addcustomcol")) { + U.cf_open = true; + U.cf_editing = false; + U.cf_edit_idx = -1; + U.cf_target_stage = 0; + U.cf_formula.clear(); + U.cf_name.clear(); + U.cf_type = ColumnType::String; + U.cf_error.clear(); + } + ImGui::PopStyleColor(3); + ImGui::SameLine(); + + bool any = false; + for (size_t i = 0; i < stage0.derived.size(); ++i) { + if (stage0.derived[i].formula.empty()) continue; + any = true; + const auto& d = stage0.derived[i]; + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s %s x##custom%zu", + column_type_icon(d.type), d.name.c_str(), i); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(140, 140, 140, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(170, 170, 170, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(110, 110, 110, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.cf_open = true; + U.cf_editing = true; + U.cf_edit_idx = (int)i; + U.cf_target_stage = 0; + U.cf_formula = d.formula; + U.cf_name = d.name; + U.cf_type = d.type; + U.cf_error.clear(); + } + if (clicked) { + if (d.lua_id >= 0) lua_engine::release(lua_engine::get(), d.lua_id); + stage0.derived.erase(stage0.derived.begin() + i); + break; + } + ImGui::SameLine(); + } + if (!any) ImGui::TextDisabled("Custom columns: + para anadir."); + else ImGui::NewLine(); + } + + // Sort chips para stage 0 (input headers para popup). + draw_sort_chips(act); + draw_add_sort_popup(act, eff_headers.data(), eff_cols, eff_types); + draw_edit_sort_popup(act, eff_headers.data(), eff_cols); + ImGui::PopStyleVar(); // ItemSpacing + } + + // Para stages 1+, compute input headers/types del stage previo. + // Esto requiere compute_stage chain. Lo haremos abajo. + + // ---------- Compute view: chain compute_stage 0..active ---------- + // Stage 0 expressions: derived cols. Pero compute_stage no sabe de Lua. + // Estrategia: stage 0 lo aplicamos a mano (orig cells + filter + sort) + // y exponemos un eff_cells "virtual" donde derived cols se llenan via Lua + // en el render. Esto preserva el path actual. + // + // Para stages 1+, compute_stage opera sobre cells materializadas. Hay que + // materializar el stage 0 output como cells reales (con derived evaluadas). + + // Simpler: si active == 0, mantener el path actual (orig cells + Lua). + // Si active > 0, materializar stage 0 + chain compute_stage(stage 1..active). + + if (is_raw) { + // ----- Path stage 0: orig cells + filters/sort manuales + Lua per cell. + + // compute_visible_rows opera sobre orig cells. filter.col es eff col, + // hay que traducir a src col (igual que codigo anterior). + State st_tmp = st; + st_tmp.ensure_stage0(); + for (auto& f : st_tmp.stages[0].filters) { + if (f.col >= 0 && f.col < eff_cols) f.col = src_for_eff[f.col]; + } + // Sort: la pasamos por @idx convention. + st_tmp.stages[0].sorts.clear(); + if (!stage0.sorts.empty()) { + // resolve col name -> col idx (de eff_cols) -> src + const SortClause& sc0 = stage0.sorts.front(); + int sc_eff = -1; + for (int c = 0; c < eff_cols; ++c) { + if (std::strcmp(eff_headers[c], sc0.col.c_str()) == 0) { sc_eff = c; break; } + } + if (sc_eff >= 0) { + int sc_src = src_for_eff[sc_eff]; + char tmp[16]; std::snprintf(tmp, sizeof(tmp), "@%d", sc_src); + st_tmp.stages[0].sorts.push_back({tmp, sc0.desc}); + } + } + auto visible_rows = compute_visible_rows(cells, row_count, orig_cols, st_tmp); + + int visible_cols = 0; + for (int k = 0; k < eff_cols; ++k) if (st.col_visible[k]) ++visible_cols; + + // Snapshot del active output (stage 0) para el config popup. + U.active_headers.clear(); + U.active_types.clear(); + for (int k = 0; k < eff_cols; ++k) { + if (!st.col_visible[k]) continue; + U.active_headers.emplace_back(eff_headers[k]); + U.active_types.push_back(eff_types[k]); + } + // Input == orig + derived (stage 0 no tiene upstream que agrupe). + U.input_headers_active = U.active_headers; + U.input_types_active = U.active_types; + + if (chrome_visible) + { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2)); + ImGui::Text("Filas: %d / %d Columnas: %d / %d", + (int)visible_rows.size(), row_count, visible_cols, eff_cols); + ImGui::SameLine(); + if (ImGui::SmallButton(U.stats_mode ? "Hide stats" : "Show stats")) { + U.stats_mode = !U.stats_mode; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Export CSV")) { + std::string out; + bool first = true; + for (int oc = 0; oc < (int)st.col_order.size(); ++oc) { + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + if (!first) out += ','; + out += csv_escape(eff_headers[c]); + first = false; + } + out += '\n'; + for (int r : visible_rows) { + first = true; + for (int oc = 0; oc < (int)st.col_order.size(); ++oc) { + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + int src = src_for_eff[c]; + if (!first) out += ','; + out += csv_escape(cells[r * orig_cols + src]); + first = false; + } + out += '\n'; + } + const char* p = fn::local_path("export_table.csv"); + std::ofstream f(p, std::ios::binary | std::ios::trunc); + if (f) { f << out; U.last_export_path = p; } + } + if (!U.last_export_path.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("-> %s", U.last_export_path.c_str()); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Show TQL")) { + std::vector orig_headers(orig_cols); + std::vector orig_types(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + orig_headers[c] = headers[c]; + orig_types[c] = eff_types[c]; + } + U.tql_show_text = tql::emit(st, orig_headers, orig_types); + U.tql_show_open = true; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Apply TQL")) { + U.tql_apply_open = true; + U.tql_apply_error.clear(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(160); + ImGui::InputText("##tql_file", U.tql_file_path, sizeof(U.tql_file_path)); + ImGui::SameLine(); + if (ImGui::SmallButton("Save .tql")) { + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { oh[c] = headers[c]; ot[c] = eff_types[c]; } + std::string text = tql::emit(st, oh, ot); + const char* path = fn::local_path(U.tql_file_path); + std::ofstream f(path); + if (f) { f << text; U.tql_io_status = std::string("saved: ") + path; } + else { U.tql_io_status = std::string("save FAILED: ") + path; } + } + ImGui::SameLine(); + if (ImGui::SmallButton("Load .tql")) { + const char* path = fn::local_path(U.tql_file_path); + std::ifstream f(path); + if (!f) { U.tql_io_status = std::string("load FAILED: ") + path; } + else { + std::string text((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { oh[c] = headers[c]; ot[c] = eff_types[c]; } + std::string err; + bool ok = tql::apply(text, st, oh, ot, cells, row_count, orig_cols, &err); + if (ok) U.tql_io_status = std::string("loaded: ") + path + (err.empty() ? "" : " (warn: " + err + ")"); + else U.tql_io_status = std::string("load parse error: ") + err; + } + } + if (!U.tql_io_status.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", U.tql_io_status.c_str()); + } + ImGui::PopStyleVar(); + } // chrome_visible + maybe_recompute_stats(cells, row_count, orig_cols, eff_cols, + st_tmp.stages[0].filters, + visible_rows, src_for_eff); + + // Toggle Table <-> View: solo visible cuando NO estamos en Table. + // Desde la tabla no se ofrece volver a chart (la tabla es estado + // canonico final). Cambia display via menu/chips si quieres ver chart. + if (st.display != ViewMode::Table) { + draw_table_toggle(st.display, U.last_non_table_main, "main", &st); + } + + // SO compartido: main viz + extras. Construido on-demand. + StageOutput so_main; + bool so_built = false; + auto build_so = [&]() -> StageOutput& { + if (so_built) return so_main; + so_built = true; + std::vector vcols; + for (int c = 0; c < eff_cols; ++c) if (st.col_visible[c]) vcols.push_back(c); + so_main.cols = (int)vcols.size(); + so_main.rows = (int)visible_rows.size(); + so_main.headers.reserve(so_main.cols); + so_main.types.reserve(so_main.cols); + for (int c : vcols) { + so_main.headers.emplace_back(eff_headers[c]); + so_main.types.push_back(eff_types[c]); + } + so_main.cell_backing.reserve((size_t)so_main.rows * so_main.cols); + for (int r : visible_rows) { + for (int c : vcols) { + if (c < orig_cols) { + const char* p = cells[r * orig_cols + c]; + so_main.cell_backing.emplace_back(p ? p : ""); + } else { + const DerivedColumn& d = stage0.derived[c - orig_cols]; + if (!d.formula.empty() && d.lua_id >= 0) { + lua_engine::RowCtx ctx; + ctx.cells = cells; + ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string err; + so_main.cell_backing.emplace_back( + lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &err)); + } else { + int src = d.source_col; + const char* sp = (src >= 0 && src < orig_cols) ? cells[r * orig_cols + src] : ""; + so_main.cell_backing.emplace_back(sp ? sp : ""); + } + } + } + } + so_main.cells.reserve(so_main.cell_backing.size()); + for (auto& s : so_main.cell_backing) so_main.cells.push_back(s.c_str()); + return so_main; + }; + + if (visible_cols == 0) { + ImGui::TextDisabled("(todas las columnas ocultas)"); + // Modales fuera del table block. + } else if (st.display != ViewMode::Table) { + viz::render(build_so(), st.display, st.viz_config, ImVec2(-1, -1)); + } else { + ImGuiTableFlags flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable(id, visible_cols, flags, ImVec2(0, 0))) { + + for (int dc = 0; dc < (int)st.col_order.size(); ++dc) { + int c = st.col_order[dc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + ImGui::TableSetupColumn(eff_headers[c], ImGuiTableColumnFlags_None, 0.0f, (ImGuiID)c); + } + ImGui::TableSetupScrollFreeze(0, 1); + + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + for (int dc = 0; dc < (int)st.col_order.size(); ++dc) { + int c = st.col_order[dc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + ImGui::TableSetColumnIndex(dc); // visual idx aprox; recomputado por engine + ImGui::PushID(c); + + // Detecta si esta col esta en sorts (primario o secundario) + int sort_pos = -1; + bool sort_desc = false; + for (size_t si = 0; si < act.sorts.size(); ++si) { + if (act.sorts[si].col == eff_headers[c]) { + sort_pos = (int)si; sort_desc = act.sorts[si].desc; break; + } + } + char arrow[16] = ""; + if (sort_pos == 0) std::snprintf(arrow, sizeof(arrow), " %s", sort_desc ? "v" : "^"); + else if (sort_pos > 0) std::snprintf(arrow, sizeof(arrow), " %s%d", sort_desc ? "v" : "^", sort_pos + 1); + char label[200]; + std::snprintf(label, sizeof(label), "%s %s%s", + column_type_icon(eff_types[c]), eff_headers[c], arrow); + + ImGui::PushStyleColor(ImGuiCol_Header, IM_COL32(45, 50, 65, 200)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, IM_COL32(65, 75, 95, 220)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, IM_COL32(80, 95, 130, 240)); + bool clicked = ImGui::Selectable(label, false, ImGuiSelectableFlags_DontClosePopups); + ImGui::PopStyleColor(3); + + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + ImGui::SetDragDropPayload("##colreorder", &c, sizeof(int)); + ImGui::Text("Move %s", eff_headers[c]); + ImGui::EndDragDropSource(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("##colreorder")) { + int src = *(const int*)p->Data; + reorder_column(st, src, c); + } + ImGui::EndDragDropTarget(); + } + if (clicked) { + bool shift = ImGui::GetIO().KeyShift; + apply_header_sort_click(act, eff_headers[c], shift); + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.header_popup_col = c; + ImGui::OpenPopup("##hdr_menu"); + } + if (ImGui::BeginPopup("##hdr_menu") && U.header_popup_col == c) { + draw_header_menu(st, act, c, eff_headers.data(), eff_cols, eff_types, orig_cols, true); + ImGui::EndPopup(); + } + + if (U.stats_mode && c < (int)U.stats_cache.size()) { + const ColStats& s = U.stats_cache[c]; + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(170, 190, 220, 220)); + ImGui::Text("missing: %d", s.empty_count); + ImGui::Text("uniq: %d%s", s.unique_count, s.unique_capped ? "+" : ""); + if (s.numeric) { + ImGui::Text("mean: %.2f", s.mean); + ImGui::Text("p25: %.2f", s.p25); + ImGui::Text("p50: %.2f", s.p50); + ImGui::Text("p75: %.2f", s.p75); + if (!s.hist.empty()) { + char overlay[64]; + std::snprintf(overlay, sizeof(overlay), "[%.2f..%.2f]", s.min, s.max); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(70, 140, 220, 230)); + ImGui::PlotHistogram("##hist", s.hist.data(), (int)s.hist.size(), + 0, overlay, 0.0f, FLT_MAX, ImVec2(-1, 36)); + ImGui::PopStyleColor(); + } + } else if (!s.top_categories.empty()) { + int mx = 0; + for (const auto& kv : s.top_categories) if (kv.second > mx) mx = kv.second; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(70, 140, 220, 220)); + for (const auto& kv : s.top_categories) { + float frac = mx > 0 ? (float)kv.second / (float)mx : 0.f; + char ovl[96]; + std::snprintf(ovl, sizeof(ovl), "%s (%d)", kv.first.c_str(), kv.second); + ImGui::ProgressBar(frac, ImVec2(-1, 12), ovl); + } + ImGui::PopStyleColor(); + } + ImGui::PopStyleColor(); + } + ImGui::PopID(); + } + + int sel_rmin = std::min(U.sel_anchor_row, U.sel_end_row); + int sel_rmax = std::max(U.sel_anchor_row, U.sel_end_row); + int sel_cmin = std::min(U.sel_anchor_col, U.sel_end_col); + int sel_cmax = std::max(U.sel_anchor_col, U.sel_end_col); + + ImGuiListClipper clipper; + clipper.Begin((int)visible_rows.size()); + while (clipper.Step()) { + for (int ri = clipper.DisplayStart; ri < clipper.DisplayEnd; ++ri) { + int r = visible_rows[ri]; + ImGui::TableNextRow(); + int draw_idx = 0; + for (int oc = 0; oc < (int)st.col_order.size(); ++oc) { + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + ImGui::TableSetColumnIndex(draw_idx++); + int src = src_for_eff[c]; + std::string eval_buf; + const char* cell; + if (c >= orig_cols && !stage0.derived[c - orig_cols].formula.empty()) { + const auto& d = stage0.derived[c - orig_cols]; + if (d.lua_id < 0) cell = "?"; + else { + lua_engine::RowCtx ctx; + ctx.cells = cells; + ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string err; + eval_buf = lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &err); + cell = eval_buf.c_str(); + } + } else { + cell = cells[r * orig_cols + src]; + } + + for (const auto& cr : st.color_rules) { + if (cr.col == c && cell && cr.equals == cell) { + ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, (ImU32)cr.color); + break; + } + } + bool in_sel = (U.sel_active && + ri >= sel_rmin && ri <= sel_rmax && + oc >= sel_cmin && oc <= sel_cmax); + ImGui::PushID(r * eff_cols + c); + // Issue 0081-N: use declarative renderer when column_specs set. + { + bool custom_rendered = false; + if (!main_t.column_specs.empty() && + c < (int)main_t.column_specs.size()) { + const ColumnSpec& cs = main_t.column_specs[(size_t)c]; + if (cs.renderer != CellRenderer::Text) { + draw_cell_custom(cs, cell, ri, c); + custom_rendered = true; + } + } + if (!custom_rendered) { + ImGui::Selectable(cell ? cell : "", in_sel, + ImGuiSelectableFlags_AllowDoubleClick); + } + } + // AllowWhenBlockedByActiveItem: durante drag, + // otras celdas tambien reciben hover -> sel se + // pinta mientras arrastras. + bool hovered = ImGui::IsItemHovered( + ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + if (hovered) { + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + U.sel_anchor_row = ri; U.sel_anchor_col = oc; + U.sel_end_row = ri; U.sel_end_col = oc; + U.sel_active = true; + U.sel_dragging = true; + } else if (U.sel_dragging) { + U.sel_end_row = ri; U.sel_end_col = oc; + } + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + U.pending_col = c; + U.pending_value = cell ? cell : ""; + U.open_cell_popup = true; + } + } + ImGui::PopID(); + } + } + } + if (U.sel_dragging && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + U.sel_dragging = false; + } + ImGui::EndTable(); + } + + // Ctrl+C -> TSV. + if (U.sel_active && ImGui::GetIO().KeyCtrl && + ImGui::IsKeyPressed(ImGuiKey_C, false)) + { + int rmin = std::min(U.sel_anchor_row, U.sel_end_row); + int rmax = std::max(U.sel_anchor_row, U.sel_end_row); + int cmin = std::min(U.sel_anchor_col, U.sel_end_col); + int cmax = std::max(U.sel_anchor_col, U.sel_end_col); + std::string out; + bool first = true; + for (int oc = cmin; oc <= cmax; ++oc) { + if (oc < 0 || oc >= (int)st.col_order.size()) continue; + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + if (!first) out += '\t'; + out += eff_headers[c]; + first = false; + } + out += '\n'; + for (int ri = rmin; ri <= rmax; ++ri) { + if (ri < 0 || ri >= (int)visible_rows.size()) continue; + int r = visible_rows[ri]; + first = true; + for (int oc = cmin; oc <= cmax; ++oc) { + if (oc < 0 || oc >= (int)st.col_order.size()) continue; + int c = st.col_order[oc]; + if (c < 0 || c >= eff_cols) continue; + if (!st.col_visible[c]) continue; + int src = src_for_eff[c]; + const char* v = cells[r * orig_cols + src]; + std::string sv = v ? v : ""; + for (char& ch : sv) if (ch == '\t' || ch == '\n') ch = ' '; + if (!first) out += '\t'; + out += sv; + first = false; + } + out += '\n'; + } + ImGui::SetClipboardText(out.c_str()); + } + } + + // Render extras panels (stage 0 path). Solo cuando display != Table — + // desde la tabla no se muestran chart panels adicionales. + if (st.display != ViewMode::Table && !st.extra_panels.empty() && visible_cols > 0) { + int close_idx = -1; + const std::vector* ep_specs = + main_t.column_specs.empty() ? nullptr : &main_t.column_specs; + for (int i = 0; i < (int)st.extra_panels.size(); ++i) { + if (draw_extra_panel(st, st.extra_panels[i], i, build_so(), ep_specs)) close_idx = i; + } + if (close_idx >= 0) st.extra_panels.erase(st.extra_panels.begin() + close_idx); + } + } else { + // ----- Path stage > 0: materializar stage 0 con cells reales + chain. + // Materializar stage 0: aplicar filters/sort sobre orig + evaluar derived. + State st_tmp = st; + for (auto& f : st_tmp.stages[0].filters) { + if (f.col >= 0 && f.col < eff_cols) f.col = src_for_eff[f.col]; + } + st_tmp.stages[0].sorts.clear(); + if (!stage0.sorts.empty()) { + const SortClause& sc0 = stage0.sorts.front(); + int sc_eff = -1; + for (int c = 0; c < eff_cols; ++c) { + if (std::strcmp(eff_headers[c], sc0.col.c_str()) == 0) { sc_eff = c; break; } + } + if (sc_eff >= 0) { + int sc_src = src_for_eff[sc_eff]; + char tmp[16]; std::snprintf(tmp, sizeof(tmp), "@%d", sc_src); + st_tmp.stages[0].sorts.push_back({tmp, sc0.desc}); + } + } + auto vrows = compute_visible_rows(cells, row_count, orig_cols, st_tmp); + + // Materializar stage0 output: cells (eff_cols) con derived evaluadas. + std::vector mat_backing; + std::vector mat_cells; + mat_backing.reserve((size_t)vrows.size() * eff_cols); + mat_cells.reserve((size_t)vrows.size() * eff_cols); + + for (int r : vrows) { + for (int c = 0; c < eff_cols; ++c) { + const char* p; + std::string buf; + if (c < orig_cols) { + p = cells[r * orig_cols + c]; + mat_backing.emplace_back(p ? p : ""); + } else { + const DerivedColumn& d = stage0.derived[c - orig_cols]; + if (!d.formula.empty()) { + if (d.lua_id < 0) { + mat_backing.emplace_back(""); + } else { + lua_engine::RowCtx ctx; + ctx.cells = cells; + ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string err; + mat_backing.emplace_back( + lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &err)); + } + } else { + // retipo puro + int src = d.source_col; + const char* sp = (src >= 0 && src < orig_cols) ? cells[r * orig_cols + src] : ""; + mat_backing.emplace_back(sp ? sp : ""); + } + } + } + } + // Punteros tras llenar backing (reserve garantiza no realloc). + for (auto& s : mat_backing) mat_cells.push_back(s.c_str()); + + std::vector cur_headers(eff_cols); + std::vector cur_types(eff_cols); + for (int c = 0; c < eff_cols; ++c) { + cur_headers[c] = eff_headers[c]; + cur_types[c] = eff_types[c]; + } + + // Chain compute_stage 1..active. + // Para encadenar, mantenemos vectores por iteracion. cur_cells apunta al + // ultimo output. + const char* const* cur_cells = mat_cells.data(); + int cur_rows = (int)vrows.size(); + int cur_cols_n = eff_cols; + + std::vector outs; + outs.reserve(st.stages.size()); + + // Headers del INPUT del active (= output del active-1) + std::vector input_headers_active = cur_headers; + std::vector input_types_active = cur_types; + + for (int si = 1; si <= active; ++si) { + const Stage& sN = st.stages[si]; + // Antes de computar: si es el active stage, los input_headers son cur_*. + if (si == active) { + input_headers_active = cur_headers; + input_types_active = cur_types; + } + StageOutput so = compute_stage(cur_cells, cur_rows, cur_cols_n, + cur_headers, cur_types, sN); + outs.push_back(std::move(so)); + const StageOutput& last = outs.back(); + cur_cells = last.cells.data(); + cur_rows = last.rows; + cur_cols_n = last.cols; + cur_headers = last.headers; + cur_types = last.types; + } + + // ----- Chips del active stage (uses input_headers_active) ----- + std::vector ih_ptrs(input_headers_active.size()); + for (size_t i = 0; i < input_headers_active.size(); ++i) + ih_ptrs[i] = input_headers_active[i].c_str(); + int in_cols_n = (int)input_headers_active.size(); + + if (chrome_visible) { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2)); + draw_filter_chips(act, ih_ptrs.data(), in_cols_n, input_types_active); + draw_add_filter_popup(act, ih_ptrs.data(), in_cols_n, input_types_active); + draw_edit_filter_popup(act, ih_ptrs.data(), in_cols_n, input_types_active); + + draw_breakout_chips(act, ih_ptrs.data(), in_cols_n, input_types_active); + draw_add_breakout_popup(act, ih_ptrs.data(), in_cols_n, input_types_active, + cur_cells, cur_rows); + draw_edit_breakout_popup(act, ih_ptrs.data(), in_cols_n); + + draw_aggregation_chips(act, ih_ptrs.data(), in_cols_n); + draw_add_aggregation_popup(act, ih_ptrs.data(), in_cols_n, input_types_active); + draw_edit_agg_popup(act, ih_ptrs.data(), in_cols_n); + + // ----- Custom column chips (stages 1+, target = active stage) ----- + { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(110, 110, 110, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(140, 140, 140, 230)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 85, 85, 85, 230)); + if (ImGui::SmallButton("+##addcustomcol_stage")) { + U.cf_open = true; + U.cf_editing = false; + U.cf_edit_idx = -1; + U.cf_target_stage = active; + U.cf_formula.clear(); + U.cf_name.clear(); + U.cf_type = ColumnType::String; + U.cf_error.clear(); + } + ImGui::PopStyleColor(3); + ImGui::SameLine(); + bool any = false; + for (size_t i = 0; i < act.derived.size(); ++i) { + if (act.derived[i].formula.empty()) continue; + any = true; + const auto& d = act.derived[i]; + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s %s x##custom_st_%zu", + column_type_icon(d.type), d.name.c_str(), i); + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(140, 140, 140, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(170, 170, 170, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(110, 110, 110, 240)); + bool clicked = ImGui::SmallButton(buf); + ImGui::PopStyleColor(3); + if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + U.cf_open = true; + U.cf_editing = true; + U.cf_edit_idx = (int)i; + U.cf_target_stage = active; + U.cf_formula = d.formula; + U.cf_name = d.name; + U.cf_type = d.type; + U.cf_error.clear(); + } + if (clicked) { + if (d.lua_id >= 0) lua_engine::release(lua_engine::get(), d.lua_id); + act.derived.erase(act.derived.begin() + i); + break; + } + ImGui::SameLine(); + } + if (!any) ImGui::TextDisabled("Custom columns (stage %d): + para anadir.", active); + else ImGui::NewLine(); + } + + draw_sort_chips(act); + // Sort col options son los headers del OUTPUT del stage activo. + std::vector out_h_ptrs(cur_headers.size()); + for (size_t i = 0; i < cur_headers.size(); ++i) out_h_ptrs[i] = cur_headers[i].c_str(); + draw_add_sort_popup(act, out_h_ptrs.data(), (int)cur_headers.size(), cur_types); + draw_edit_sort_popup(act, out_h_ptrs.data(), (int)cur_headers.size()); + ImGui::PopStyleVar(); + } // chrome_visible + + // ----- Materializar act.derived sobre cur_cells ----- + // Para cada derived col formula del active stage, eval per output row. + std::vector ext_backing; + std::vector ext_cells; + std::vector ext_headers; + std::vector ext_types; + if (!act.derived.empty()) { + int orig_out_cols = cur_cols_n; + std::vector out_hn = cur_headers; + std::unordered_map out_n2c; + for (size_t i = 0; i < out_hn.size(); ++i) out_n2c[out_hn[i]] = (int)i; + int n_derived = (int)act.derived.size(); + int new_cols = orig_out_cols + n_derived; + ext_backing.reserve((size_t)cur_rows * n_derived); + ext_cells.reserve((size_t)cur_rows * new_cols); + for (int r = 0; r < cur_rows; ++r) { + // copia cols originales del output + for (int c = 0; c < orig_out_cols; ++c) { + ext_cells.push_back(cur_cells[r * orig_out_cols + c]); + } + // anade derived eval + for (int k = 0; k < n_derived; ++k) { + const DerivedColumn& d = act.derived[k]; + if (d.formula.empty() || d.lua_id < 0) { + ext_backing.emplace_back(""); + } else { + lua_engine::RowCtx ctx; + ctx.cells = cur_cells; + ctx.orig_cols = orig_out_cols; + ctx.row = r; + ctx.header_names = &out_hn; + ctx.name_to_col = &out_n2c; + ctx.types_orig = cur_types.data(); + ctx.n_types_orig = orig_out_cols; + std::string e; + ext_backing.emplace_back( + lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &e)); + } + // marker placeholder; sera replaced abajo tras backing estable + ext_cells.push_back(nullptr); + } + } + // Construir ext_cells reemplazando placeholders por punteros estables. + size_t bi = 0; + for (int r = 0; r < cur_rows; ++r) { + for (int k = 0; k < n_derived; ++k) { + int idx = r * new_cols + orig_out_cols + k; + ext_cells[idx] = ext_backing[bi++].c_str(); + } + } + ext_headers = cur_headers; + ext_types = cur_types; + for (int k = 0; k < n_derived; ++k) { + ext_headers.push_back(act.derived[k].name); + ext_types.push_back(act.derived[k].type); + } + cur_cells = ext_cells.data(); + cur_cols_n = new_cols; + cur_headers = ext_headers; + cur_types = ext_types; + } + + // Header row + cells render simple (sin clipper porque outputs son + // pequenos tipicamente). + // Snapshot del active output (stage>0) para config popup. + U.active_headers = cur_headers; + U.active_types = cur_types; + // Input del active stage = output del previo. Disponible en + // input_headers_active/input_types_active. + U.input_headers_active = input_headers_active; + U.input_types_active = input_types_active; + + if (chrome_visible) { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2)); + ImGui::Text("Filas: %d Columnas: %d", cur_rows, cur_cols_n); + ImGui::SameLine(); + if (ImGui::SmallButton(U.stats_mode ? "Hide stats" : "Show stats")) { + U.stats_mode = !U.stats_mode; + } + // Recompute stats sobre cur_cells del stage activo. + if (U.stats_mode && cur_cols_n > 0) { + U.stats_cache.resize(cur_cols_n); + U.last_cells = cur_cells; + for (int c = 0; c < cur_cols_n; ++c) { + U.stats_cache[c] = compute_column_stats(cur_cells, cur_rows, cur_cols_n, c); + } + } + ImGui::SameLine(); + if (ImGui::SmallButton("Show TQL")) { + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + oh[c] = headers[c]; + ot[c] = eff_types[c]; + } + U.tql_show_text = tql::emit(st, oh, ot); + U.tql_show_open = true; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Apply TQL")) { + U.tql_apply_open = true; + U.tql_apply_error.clear(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(160); + ImGui::InputText("##tql_file2", U.tql_file_path, sizeof(U.tql_file_path)); + ImGui::SameLine(); + if (ImGui::SmallButton("Save .tql##s2")) { + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { oh[c] = headers[c]; ot[c] = eff_types[c]; } + std::string text = tql::emit(st, oh, ot); + const char* path = fn::local_path(U.tql_file_path); + std::ofstream f(path); + if (f) { f << text; U.tql_io_status = std::string("saved: ") + path; } + else { U.tql_io_status = std::string("save FAILED: ") + path; } + } + ImGui::SameLine(); + if (ImGui::SmallButton("Load .tql##l2")) { + const char* path = fn::local_path(U.tql_file_path); + std::ifstream f(path); + if (!f) { U.tql_io_status = std::string("load FAILED: ") + path; } + else { + std::string text((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + std::vector oh(orig_cols); + std::vector ot(orig_cols); + for (int c = 0; c < orig_cols; ++c) { oh[c] = headers[c]; ot[c] = eff_types[c]; } + std::string err; + bool ok = tql::apply(text, st, oh, ot, cells, row_count, orig_cols, &err); + if (ok) U.tql_io_status = std::string("loaded: ") + path + (err.empty() ? "" : " (warn: " + err + ")"); + else U.tql_io_status = std::string("load parse error: ") + err; + } + } + if (!U.tql_io_status.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", U.tql_io_status.c_str()); + } + ImGui::PopStyleVar(); + } // chrome_visible + + // Toggle Table <-> View: solo visible cuando NO estamos en Table. + if (st.display != ViewMode::Table) { + draw_table_toggle(st.display, U.last_non_table_main, "main2", &st); + } + + if (st.display != ViewMode::Table && cur_cols_n > 0) { + // outs.back() es el StageOutput del active. Si active no tiene outs + // (cur_rows poblado pero outs vacio cuando active>0 y chain corta), + // construir uno on-the-fly desde cur_cells. + StageOutput so_local; + const StageOutput* so_ptr = nullptr; + if (!outs.empty()) { + so_ptr = &outs.back(); + } else { + so_local.cols = cur_cols_n; + so_local.rows = cur_rows; + so_local.headers = cur_headers; + so_local.types = cur_types; + so_local.cells.reserve((size_t)cur_rows * cur_cols_n); + for (int i = 0; i < cur_rows * cur_cols_n; ++i) + so_local.cells.push_back(cur_cells[i]); + so_ptr = &so_local; + } + int clicked_row = -1; + viz::render(*so_ptr, st.display, st.viz_config, ImVec2(-1, -1), &clicked_row); + // Fase 10: click sobre chart -> drill al stage previo usando + // breakout col[0] como filtro Op::Eq sobre cells[clicked_row]. + if (clicked_row >= 0 && active > 0 && + so_ptr->cols > 0 && clicked_row < so_ptr->rows) { + int n_brk = (int)st.stages[active].breakouts.size(); + if (n_brk > 0) { + const char* v = so_ptr->cells[clicked_row * so_ptr->cols + 0]; + std::string col_clean; + parse_breakout_granularity(so_ptr->headers[0], col_clean); + drill_into(st, active, col_clean, + v ? std::string(v) : "", + input_headers_active); + } + } + goto stage_n_table_end; + } + + { + ImGuiTableFlags flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY; + if (cur_cols_n > 0 && ImGui::BeginTable(id, cur_cols_n, flags, ImVec2(0, 0))) { + for (int c = 0; c < cur_cols_n; ++c) { + ImGui::TableSetupColumn(cur_headers[c].c_str(), + ImGuiTableColumnFlags_None, 0.0f, (ImGuiID)c); + } + ImGui::TableSetupScrollFreeze(0, 1); + + // Custom header row: nombre + icon + stats inline si stats_mode. + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + for (int c = 0; c < cur_cols_n; ++c) { + ImGui::TableSetColumnIndex(c); + // Sort indicator + int sort_pos = -1; + bool sort_desc = false; + for (size_t si = 0; si < act.sorts.size(); ++si) { + if (act.sorts[si].col == cur_headers[c]) { + sort_pos = (int)si; sort_desc = act.sorts[si].desc; break; + } + } + char arrow[16] = ""; + if (sort_pos == 0) std::snprintf(arrow, sizeof(arrow), " %s", sort_desc ? "v" : "^"); + else if (sort_pos > 0) std::snprintf(arrow, sizeof(arrow), " %s%d", sort_desc ? "v" : "^", sort_pos + 1); + + ImGui::PushStyleColor(ImGuiCol_Header, IM_COL32(45, 50, 65, 200)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, IM_COL32(65, 75, 95, 220)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, IM_COL32(80, 95, 130, 240)); + char lbl[200]; + std::snprintf(lbl, sizeof(lbl), "%s %s%s", + column_type_icon(cur_types[c]), + cur_headers[c].c_str(), arrow); + bool h_clicked = ImGui::Selectable(lbl, false, ImGuiSelectableFlags_DontClosePopups); + ImGui::PopStyleColor(3); + if (h_clicked) { + bool shift = ImGui::GetIO().KeyShift; + apply_header_sort_click(act, cur_headers[c], shift); + } + + if (U.stats_mode && c < (int)U.stats_cache.size()) { + const ColStats& s = U.stats_cache[c]; + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(170, 190, 220, 220)); + ImGui::Text("missing: %d", s.empty_count); + ImGui::Text("uniq: %d%s", s.unique_count, s.unique_capped ? "+" : ""); + if (s.numeric) { + ImGui::Text("mean: %.2f", s.mean); + ImGui::Text("p25: %.2f", s.p25); + ImGui::Text("p50: %.2f", s.p50); + ImGui::Text("p75: %.2f", s.p75); + if (!s.hist.empty()) { + char overlay[64]; + std::snprintf(overlay, sizeof(overlay), "[%.2f..%.2f]", s.min, s.max); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(70, 140, 220, 230)); + ImGui::PlotHistogram("##hist", s.hist.data(), (int)s.hist.size(), + 0, overlay, 0.0f, FLT_MAX, ImVec2(-1, 36)); + ImGui::PopStyleColor(); + } + } else if (!s.top_categories.empty()) { + int mx = 0; + for (const auto& kv : s.top_categories) if (kv.second > mx) mx = kv.second; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, IM_COL32(70, 140, 220, 220)); + for (const auto& kv : s.top_categories) { + float frac = mx > 0 ? (float)kv.second / (float)mx : 0.f; + char ovl[96]; + std::snprintf(ovl, sizeof(ovl), "%s (%d)", kv.first.c_str(), kv.second); + ImGui::ProgressBar(frac, ImVec2(-1, 12), ovl); + } + ImGui::PopStyleColor(); + } + ImGui::PopStyleColor(); + } + } + + int n_brk = (int)st.stages[active].breakouts.size(); + + for (int r = 0; r < cur_rows; ++r) { + ImGui::TableNextRow(); + for (int c = 0; c < cur_cols_n; ++c) { + ImGui::TableSetColumnIndex(c); + const char* cell = cur_cells[r * cur_cols_n + c]; + ImGui::PushID(r * cur_cols_n + c); + // Issue 0081-N: declarative renderer for aggregated stage tables. + { + bool custom_rendered = false; + if (!main_t.column_specs.empty() && + c < (int)main_t.column_specs.size()) { + const ColumnSpec& cs = main_t.column_specs[(size_t)c]; + if (cs.renderer != CellRenderer::Text) { + draw_cell_custom(cs, cell, r, c); + custom_rendered = true; + } + } + if (!custom_rendered) { + ImGui::Selectable(cell ? cell : ""); + } + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + U.pending_col = c; + U.pending_value = cell ? cell : ""; + U.inspect_row = r; + ImGui::OpenPopup("##drill_popup"); + } + if (ImGui::BeginPopup("##drill_popup")) { + if (c < n_brk) { + char lbl[256]; + std::snprintf(lbl, sizeof(lbl), "Drill into: %s = %s", + cur_headers[c].c_str(), cell ? cell : ""); + if (ImGui::MenuItem(lbl)) { + drill_into(st, active, cur_headers[c], + cell ? std::string(cell) : "", + input_headers_active); + ImGui::CloseCurrentPopup(); + } + ImGui::Separator(); + } + if (ImGui::MenuItem("Inspect row...")) { + U.inspect_row = r; + U.inspect_open = true; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + ImGui::PopID(); + } + } + ImGui::EndTable(); + } + } + stage_n_table_end:; + + // Row inspector modal (fase 10). Activado via right-click "Inspect row..." + // sobre celdas del table del stage activo. `cur_cells` ya es row-major. + draw_row_inspector_modal(st, active, cur_cells, cur_rows, cur_cols_n, + cur_headers, cur_types, input_headers_active); + + // Render extras (stage>0 path). Solo cuando display != Table. + if (st.display != ViewMode::Table && !st.extra_panels.empty() && cur_cols_n > 0) { + StageOutput so_local; + const StageOutput* so_ptr = nullptr; + if (!outs.empty()) { + so_ptr = &outs.back(); + } else { + so_local.cols = cur_cols_n; + so_local.rows = cur_rows; + so_local.headers = cur_headers; + so_local.types = cur_types; + so_local.cells.reserve((size_t)cur_rows * cur_cols_n); + for (int i = 0; i < cur_rows * cur_cols_n; ++i) + so_local.cells.push_back(cur_cells[i]); + so_ptr = &so_local; + } + int close_idx = -1; + const std::vector* ep_specs2 = + main_t.column_specs.empty() ? nullptr : &main_t.column_specs; + for (int i = 0; i < (int)st.extra_panels.size(); ++i) { + if (draw_extra_panel(st, st.extra_panels[i], i, *so_ptr, ep_specs2)) close_idx = i; + } + if (close_idx >= 0) st.extra_panels.erase(st.extra_panels.begin() + close_idx); + } + } + + // ---------- Modales (comunes a ambos paths) ---------- + if (U.cf_open) ImGui::OpenPopup("Custom column"); + if (ImGui::BeginPopupModal("Custom column", &U.cf_open, + ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("Nombre:"); + char name_buf[128] = {0}; + std::snprintf(name_buf, sizeof(name_buf), "%s", U.cf_name.c_str()); + ImGui::SetNextItemWidth(520); + if (ImGui::InputText("##cfname", name_buf, sizeof(name_buf))) U.cf_name = name_buf; + + ImGui::Spacing(); + ImGui::Text("Formula (Lua). Acceso celdas via row. o row[idx]."); + ImGui::TextDisabled("Ejemplo: return row.size_kb * 1024"); + + static char formula_buf[4096] = {0}; + if (U.cf_force_cursor || std::strcmp(formula_buf, U.cf_formula.c_str()) != 0) { + std::snprintf(formula_buf, sizeof(formula_buf), "%s", U.cf_formula.c_str()); + } + ImGuiInputTextFlags flags = + ImGuiInputTextFlags_CallbackEdit | ImGuiInputTextFlags_CallbackAlways; + if (ImGui::InputTextMultiline("##cfformula", formula_buf, sizeof(formula_buf), + ImVec2(520, 200), flags, autocomplete_cb, &U)) { + U.cf_formula = formula_buf; + } + if (U.cf_ac_open) { + ImVec2 box_min = ImGui::GetItemRectMin(); + ImVec2 box_max = ImGui::GetItemRectMax(); + ImGui::SetNextWindowPos(ImVec2(box_min.x + 20, box_max.y + 4)); + ImGui::SetNextWindowSize(ImVec2(280, 0)); + ImGuiWindowFlags wf = + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_AlwaysAutoResize; + if (ImGui::Begin("##colpicker", nullptr, wf)) { + ImGui::TextDisabled("Pick column:"); + ImGui::Separator(); + auto ci_contains = [](const std::string& hay, const std::string& nd) { + if (nd.empty()) return true; + std::string a = hay, b = nd; + for (char& c : a) if (c >= 'A' && c <= 'Z') c += 32; + for (char& c : b) if (c >= 'A' && c <= 'Z') c += 32; + return a.find(b) != std::string::npos; + }; + int shown = 0; + for (int c = 0; c < eff_cols && shown < 12; ++c) { + std::string nm = eff_headers[c]; + if (!ci_contains(nm, U.cf_ac_filter)) continue; + char lbl[200]; + std::snprintf(lbl, sizeof(lbl), "%s %s", + column_type_icon(eff_types[c]), nm.c_str()); + if (ImGui::Selectable(lbl)) { + int new_cursor = 0; + std::string updated = insert_column_ref( + U.cf_formula, U.cf_ac_start, U.cf_ac_cursor, nm, new_cursor); + U.cf_formula = updated; + U.cf_target_cursor= new_cursor; + U.cf_force_cursor = true; + U.cf_ac_open = false; + } + ++shown; + } + if (shown == 0) ImGui::TextDisabled("(sin matches)"); + } + ImGui::End(); + } + + if (!U.cf_error.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(230, 100, 100, 255)); + ImGui::TextWrapped("Error: %s", U.cf_error.c_str()); + ImGui::PopStyleColor(); + } + + if (ImGui::Button("Compile & save")) { + std::string err; + int lid = lua_engine::compile(lua_engine::get(), U.cf_formula, &err); + if (lid < 0) { + U.cf_error = err; + } else { + // Build sample context segun cf_target_stage. + // target == 0: usa orig cells + stage 0 derived. + // target > 0: recomputa chain hasta el target (excluyendo + // derived del target) y sample sobre ese output. + int ts = U.cf_target_stage; + if (ts < 0 || ts >= (int)st.stages.size()) ts = 0; + int sample = 0; + std::vector samples_str; + + if (ts == 0) { + sample = std::min(64, row_count); + for (int r = 0; r < sample; ++r) { + lua_engine::RowCtx ctx; + ctx.cells = cells; + ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string e; + samples_str.emplace_back( + lua_engine::eval(lua_engine::get(), lid, ctx, &e)); + } + } else { + // Recompute chain hasta stage ts output (sin aplicar derived + // del propio ts). + State st_sample = st; + // Limpia derived del target stage para que el sample no + // se referencie a si mismo. + if (ts < (int)st_sample.stages.size()) + st_sample.stages[ts].derived.clear(); + // Reusa la logica de materializacion: simple recompute manual. + // Aplica stage 0 (orig + derived) materializado. + State stmp = st; + Stage& s0 = stmp.stages[0]; + for (auto& f : s0.filters) { + if (f.col >= 0 && f.col < eff_cols) f.col = src_for_eff[f.col]; + } + s0.sorts.clear(); + auto v0 = compute_visible_rows(cells, row_count, orig_cols, stmp); + + std::vector mb; + std::vector mc; + mb.reserve((size_t)v0.size() * eff_cols); + mc.reserve((size_t)v0.size() * eff_cols); + for (int r : v0) { + for (int c = 0; c < eff_cols; ++c) { + if (c < orig_cols) { + const char* p = cells[r * orig_cols + c]; + mb.emplace_back(p ? p : ""); + } else { + const DerivedColumn& d = stage0.derived[c - orig_cols]; + if (!d.formula.empty() && d.lua_id >= 0) { + lua_engine::RowCtx ctx; + ctx.cells = cells; ctx.orig_cols = orig_cols; + ctx.row = r; + ctx.header_names = &hn_storage; + ctx.name_to_col = &name_to_col; + ctx.types_orig = eff_types.data(); + ctx.n_types_orig = orig_cols; + ctx.derived = &stage0.derived; + ctx.derived_name_to_idx = &derived_n2i; + std::string e; + mb.emplace_back(lua_engine::eval(lua_engine::get(), d.lua_id, ctx, &e)); + } else if (d.source_col >= 0) { + const char* p = cells[r * orig_cols + d.source_col]; + mb.emplace_back(p ? p : ""); + } else mb.emplace_back(""); + } + } + } + for (auto& s : mb) mc.push_back(s.c_str()); + + std::vector ch(eff_cols); + std::vector ct(eff_cols); + for (int c = 0; c < eff_cols; ++c) { ch[c] = eff_headers[c]; ct[c] = eff_types[c]; } + + const char* const* cc = mc.data(); + int cr = (int)v0.size(); + int cn = eff_cols; + std::vector tmps; + for (int si = 1; si <= ts; ++si) { + Stage stage_sn = st.stages[si]; + // En el target stage NO apliques sus propias derived. + if (si == ts) stage_sn.derived.clear(); + tmps.push_back(compute_stage(cc, cr, cn, ch, ct, stage_sn)); + const StageOutput& l = tmps.back(); + cc = l.cells.data(); cr = l.rows; cn = l.cols; + ch = l.headers; ct = l.types; + } + // Build name_to_col map for the target stage output. + std::vector hn_t = ch; + std::unordered_map n2c_t; + for (size_t i = 0; i < hn_t.size(); ++i) n2c_t[hn_t[i]] = (int)i; + sample = std::min(64, cr); + for (int r = 0; r < sample; ++r) { + lua_engine::RowCtx ctx; + ctx.cells = cc; + ctx.orig_cols = cn; + ctx.row = r; + ctx.header_names = &hn_t; + ctx.name_to_col = &n2c_t; + ctx.types_orig = ct.data(); + ctx.n_types_orig = cn; + std::string e; + samples_str.emplace_back( + lua_engine::eval(lua_engine::get(), lid, ctx, &e)); + } + } + + std::vector samples_ptr; + samples_ptr.reserve(samples_str.size()); + for (auto& s : samples_str) samples_ptr.push_back(s.c_str()); + ColumnType auto_t = auto_detect_type(samples_ptr.data(), + (int)samples_ptr.size(), 1, 0); + + // Save to target stage. + if (ts < 0 || ts >= (int)st.stages.size()) ts = 0; + auto& target_derived = st.stages[ts].derived; + if (U.cf_editing && U.cf_edit_idx >= 0 && + U.cf_edit_idx < (int)target_derived.size()) + { + auto& d = target_derived[U.cf_edit_idx]; + if (d.lua_id >= 0) lua_engine::release(lua_engine::get(), d.lua_id); + d.formula = U.cf_formula; + d.name = U.cf_name.empty() ? "custom" : U.cf_name; + d.type = auto_t; + d.lua_id = lid; + d.compile_error.clear(); + } else { + DerivedColumn d; + d.source_col = -1; + d.type = auto_t; + d.name = U.cf_name.empty() ? "custom" : U.cf_name; + d.formula = U.cf_formula; + d.lua_id = lid; + target_derived.push_back(d); + } + U.cf_open = false; + U.cf_error.clear(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + U.cf_open = false; + U.cf_error.clear(); + } + ImGui::EndPopup(); + } + + if (U.tql_show_open) ImGui::OpenPopup("Show TQL"); + if (ImGui::BeginPopupModal("Show TQL", &U.tql_show_open, + ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("TQL serializado del estado actual (read-only):"); + ImGui::InputTextMultiline("##tqlshow", U.tql_show_text.data(), + U.tql_show_text.size() + 1, + ImVec2(560, 280), + ImGuiInputTextFlags_ReadOnly); + if (ImGui::Button("Copy to clipboard")) { + ImGui::SetClipboardText(U.tql_show_text.c_str()); + } + ImGui::SameLine(); + if (ImGui::Button("Close")) U.tql_show_open = false; + ImGui::EndPopup(); + } + + if (U.tql_apply_open) ImGui::OpenPopup("Apply TQL"); + if (ImGui::BeginPopupModal("Apply TQL", &U.tql_apply_open, + ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("Pega un chunk TQL (Lua). Ver docs/TQL.md para sintaxis."); + static char tql_buf[8192] = {0}; + if (std::strcmp(tql_buf, U.tql_apply_text.c_str()) != 0) { + std::snprintf(tql_buf, sizeof(tql_buf), "%s", U.tql_apply_text.c_str()); + } + if (ImGui::InputTextMultiline("##tqlapply", tql_buf, sizeof(tql_buf), + ImVec2(560, 280))) { + U.tql_apply_text = tql_buf; + } + if (!U.tql_apply_error.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(230, 100, 100, 255)); + ImGui::TextWrapped("Error: %s", U.tql_apply_error.c_str()); + ImGui::PopStyleColor(); + } + if (ImGui::Button("Apply")) { + std::vector orig_headers(orig_cols); + std::vector orig_types(orig_cols); + for (int c = 0; c < orig_cols; ++c) { + orig_headers[c] = headers[c]; + orig_types[c] = eff_types[c]; + } + std::string err; + bool ok = tql::apply(U.tql_apply_text, st, orig_headers, orig_types, + cells, row_count, orig_cols, &err); + if (ok) { + U.tql_apply_open = false; + U.tql_apply_error.clear(); + } else { + U.tql_apply_error = err; + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + U.tql_apply_open = false; + U.tql_apply_error.clear(); + } + ImGui::EndPopup(); + } + + // Ask AI modal (fase 11 — issue 0080). + if (U.ask_open) ImGui::OpenPopup("Ask AI"); + ImGui::SetNextWindowSize(ImVec2(820, 560), ImGuiCond_Appearing); + if (ImGui::BeginPopupModal("Ask AI", &U.ask_open, + ImGuiWindowFlags_NoSavedSettings)) { + ImGui::TextDisabled("Ask en lenguaje natural. Default TQL. SQL solo si DuckDB linkado."); + const char* modes[] = {"TQL", "SQL (DuckDB)"}; +#ifndef FN_TQL_DUCKDB + // SQL mode disabled visually pero el toggle existe (informativo) + if (U.ask_mode == 1) U.ask_mode = 0; +#endif + ImGui::Combo("Output##askmode", &U.ask_mode, modes, IM_ARRAYSIZE(modes)); +#ifndef FN_TQL_DUCKDB + if (U.ask_mode == 1) { + ImGui::TextColored(ImVec4(1, 0.5f, 0.3f, 1), + "SQL mode requires FN_TQL_DUCKDB=1 build flag."); + } +#endif + ImGui::InputTextMultiline("##ask_q", U.ask_question, sizeof(U.ask_question), + ImVec2(-1, 80)); + ImGui::BeginDisabled(U.ask_busy); + if (ImGui::Button("Send")) { + U.ask_busy = true; + U.ask_status = "Sending..."; + U.ask_error.clear(); + U.ask_response_code.clear(); + U.ask_response_raw.clear(); + + // Build AskInput desde el state actual. + llm_anthropic::AskInput in; + in.question = U.ask_question; + in.tql_current = U.ask_current_tql; + in.col_names = U.active_headers; + in.col_types = U.active_types; + in.mode = (U.ask_mode == 1) + ? llm_anthropic::OutputMode::SQL + : llm_anthropic::OutputMode::TQL; + + // Llamada blocking (UI freeze breve durante red). + auto r = llm_anthropic::ask(in); + U.ask_busy = false; + if (!r.error.empty()) { + U.ask_error = r.error; + U.ask_status = "Error"; + } else { + U.ask_response_raw = r.raw; + U.ask_response_code = r.code; + U.ask_status = "Got response."; + // Llenar edit buffer + std::snprintf(U.ask_edit_buf, sizeof(U.ask_edit_buf), + "%s", r.code.c_str()); + } + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (!U.ask_status.empty()) { + ImGui::TextDisabled("%s", U.ask_status.c_str()); + } + if (!U.ask_error.empty()) { + ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", U.ask_error.c_str()); + } + ImGui::Separator(); + ImGui::Columns(2, "ask_cols", true); + ImGui::TextUnformatted("Current"); + ImGui::InputTextMultiline("##ask_cur", + const_cast(U.ask_current_tql.c_str()), + U.ask_current_tql.size() + 1, + ImVec2(-1, 240), + ImGuiInputTextFlags_ReadOnly); + ImGui::NextColumn(); + ImGui::TextUnformatted("Proposed (editable before apply)"); + ImGui::InputTextMultiline("##ask_new", U.ask_edit_buf, sizeof(U.ask_edit_buf), + ImVec2(-1, 240)); + ImGui::Columns(1); + + bool can_apply = !U.ask_busy && U.ask_edit_buf[0] != '\0'; + ImGui::BeginDisabled(!can_apply); + if (ImGui::Button("Apply")) { + std::string err; + if (U.ask_mode == 0) { + // TQL apply + bool ok = tql::apply(U.ask_edit_buf, st, + U.active_headers, + U.active_types, + nullptr, 0, + (int)U.active_headers.size(), + &err); + if (ok) { + U.ask_status = "Applied OK."; + U.ask_open = false; + } else { + U.ask_error = "tql::apply error: " + err; + U.ask_status = "Apply failed."; + } + } else { +#ifdef FN_TQL_DUCKDB + // SQL apply: ejecutar via tql_duckdb sobre TableInputs activas. + // Para tablas en memoria construimos un TableInput basico desde + // active_headers/types. v1 no recupera cells originales aqui; + // reportamos solo error si fallo. Caller real deberia pasar + // tables() del render scope. Sin esto, marcamos status info. + U.ask_status = "SQL execute disponible (FN_TQL_DUCKDB ON). " + "Integracion full pendiente: usar tql_duckdb::execute desde caller."; +#else + U.ask_status = "SQL execute requires FN_TQL_DUCKDB build flag."; +#endif + } + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Reject")) { + U.ask_response_code.clear(); + U.ask_edit_buf[0] = '\0'; + } + ImGui::SameLine(); + if (ImGui::Button("Close")) { + U.ask_open = false; + } + ImGui::EndPopup(); + } + + if (U.open_cell_popup) { ImGui::OpenPopup("##cell_op"); U.open_cell_popup = false; } + if (ImGui::BeginPopup("##cell_op")) { + ColumnType t = (U.pending_col >= 0 && U.pending_col < eff_cols) + ? eff_types[U.pending_col] : ColumnType::String; + const char* hdr = (U.pending_col >= 0 && U.pending_col < eff_cols) + ? eff_headers[U.pending_col] : "?"; + ImGui::TextDisabled("%s %s ?? \"%s\"", + column_type_icon(t), hdr, U.pending_value.c_str()); + ImGui::Separator(); + auto ops = ops_for_type(t); + for (Op o : ops) { + if (ImGui::MenuItem(op_label(o))) { + st.stages[0].filters.push_back({U.pending_col, o, U.pending_value}); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } +} + +} // namespace data_table diff --git a/cpp/functions/viz/data_table.md b/cpp/functions/viz/data_table.md new file mode 100644 index 00000000..cd5c9c70 --- /dev/null +++ b/cpp/functions/viz/data_table.md @@ -0,0 +1,147 @@ +--- +name: data_table +kind: function +lang: cpp +domain: viz +version: "1.1.0" +purity: impure +signature: "void data_table::render(const char* id, const std::vector& tables, State& st, bool show_chrome = true)" +description: "Render UI completa de tabla TQL: chips bar, tabla, viz panels, column-stats inline, drill, color rules, joins, TQL editor, Ask AI. Entry-point publica del stack data_table. Muta State segun interaccion del usuario." +tags: [tables, viz, ui, imgui, tql, cpp-tables] +uses_functions: + - compute_stage_cpp_core + - compute_pipeline_cpp_core + - compute_column_stats_cpp_core + - auto_detect_type_cpp_core + - tql_emit_cpp_core + - tql_apply_cpp_core + - tql_helpers_cpp_core + - tql_to_sql_cpp_core + - lua_engine_cpp_core + - join_tables_cpp_core + - viz_render_cpp_viz +uses_types: + - data_table_types_cpp_core + - ColumnSpec_cpp_core + - CellRenderer_cpp_core + - BadgeRule_cpp_core + - IconMapEntry_cpp_core + - TableInput_cpp_core + - State_cpp_core + - Stage_cpp_core + - StageOutput_cpp_core + - ViewMode_cpp_viz + - ViewConfig_cpp_viz + - VizPanel_cpp_viz + - Join_cpp_core + - Filter_cpp_core + - DrillStep_cpp_core + - DerivedColumn_cpp_core + - Aggregation_cpp_core + - SortClause_cpp_core + - ColumnType_cpp_core +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: + - imgui.h + - app_base.h + - core/data_table_types.h + - core/lua_engine.h + - core/tql_apply.h + - core/tql_emit.h + - core/tql_helpers.h + - core/tql_to_sql.h + - core/compute_stage.h + - core/compute_pipeline.h + - core/compute_column_stats.h + - core/auto_detect_type.h + - core/join_tables.h + - viz/viz_render.h +tested: true +tests: + - "back-compat: TableInput without column_specs does not crash" + - "Badge: TableInput with Badge column_spec compiles and links" + - "Progress: TableInput with Progress column_spec compiles and links" + - "Duration: TableInput with Duration column_spec compiles and links" + - "Icon: TableInput with Icon column_spec compiles and links" +test_file_path: "cpp/tests/test_column_specs.cpp" +file_path: "cpp/functions/viz/data_table.cpp" +params: + - name: id + desc: "ID unico ImGui para esta instancia, ej. '##orders_table'. Debe ser estable entre frames." + - name: tables + desc: "Lista de TableInput materializadas en memoria. tables[0] es la main por defecto; si State.main_source no-vacio se usa por nombre. Tablas extra se exponen como joinables en la UI de joins." + - name: st + desc: "Estado mutable completo: pipeline de stages, joins, viz config, ui tweaks. Debe persistir entre frames — no declarar en el stack del frame." + - name: show_chrome + desc: "Si false, oculta chips bar + breadcrumb por defecto. El usuario puede reactivar con el boton 'Show UI'. El State persiste el override del usuario entre frames." +output: "void. Muta st en respuesta a la interaccion del usuario (filtros, breakouts, sorts, drill, joins, viz mode). Los cambios son visibles en st al retornar." +--- + +## Ejemplo + +```cpp +#include "viz/data_table.h" +#include "core/data_table_types.h" + +// --- Setup (una vez) --- +data_table::TableInput t; +t.name = "orders"; +t.rows = num_rows; +t.cols = num_cols; +t.cells = cells_ptr; // row-major flat array, owner externo +t.headers = {"id", "amount", "status"}; +t.types = {data_table::ColumnType::Int, + data_table::ColumnType::Float, + data_table::ColumnType::String}; + +data_table::State st; // persiste entre frames + +// --- Render (cada frame) --- +ImGui::Begin("Orders"); +ImGui::BeginChild("##tbl", ImVec2(-1, -1)); +data_table::render("##orders", {t}, st); +ImGui::EndChild(); +ImGui::End(); +``` + +## Cuando usarla + +Cuando una app necesita tabla con filtros + agregaciones + viz + joins sobre datos en memoria. Reemplaza `ImGui::BeginTable` inline + toda la logica TQL manual. Sustituye directamente el include del playground (`tables/data_table.h`) cambiando solo el path a `viz/data_table.h`. + +## Gotchas + +- **ImGui + ImPlot context activos**: `render()` llama a APIs de ambas librerias. Llamar fuera de un frame activo causa UB. +- **State no stack-local**: `State` contiene el historial de drill, pipeline de stages, cache de stats y buffers de UI. Declarar en el stack del frame reset todo el estado del usuario en cada frame. +- **Drill-down propaga en State**: `st.active_stage` y `st.stages` se mutan por click en charts. El caller puede leer `st` tras `render()` para reaccionar. +- **Thread-safety**: `render()` usa `static thread_local` para buffers intermedios. Llamar solo desde el main thread de ImGui. +- **TableInput owner externo**: `cells` es un puntero raw al array del caller. Los datos deben sobrevivir durante toda la llamada a `render()`. No pasar puntero a vector que puede reallocarse. +- **Ask AI modal (llm_anthropic)**: el boton "Ask AI" usa un stub interno de `llm_anthropic` que retorna error por defecto. Para activar la feature real, compilar con `-DFN_LLM_ANTHROPIC=1` y proveer `infra/llm_anthropic.h` en el include path. Pendiente Wave 4: promover al registry. +- **FN_TQL_DUCKDB**: modo SQL del Ask AI requiere compilar con `-DFN_TQL_DUCKDB=1` y la libreria DuckDB disponible. + +## Notas + +No hay tests unitarios directos: `render()` requiere ImGui + ImPlot context activos (imposible sin ventana GL). Cobertura via: +1. `cpp/apps/primitives_gallery/playground/tables/` — playground original con self_test.cpp y e2e_run.sh. +2. Wave 4: migration self-tests en las apps que migren desde el playground. + +**Estado Wave 3.5 (issue 0081-I):** +- Todos los includes del playground (`data_table_logic.h`, `tql.h`, `tql_to_sql.h`) eliminados. `data_table.cpp` compila sin el playground en el include path. +- `tql::apply` firma extendida ya en `tql_apply_cpp_core` (wave anterior). Resuelto. +- `tql_to_sql` promovido a `core/tql_to_sql.h`. Resuelto. +- `data_table_logic` helpers (row_to_tsv, drill, view_mode, etc.) declarados como `static` en `data_table.cpp`. No son API pública. +- `State::ensure_stage0/raw/active` implementados en `compute_stage.cpp`. +- `ColStats` struct: usa el de `compute_column_stats_cpp_core`. Unificado. + +**Deuda tecnica restante (Wave 4):** +- `llm_anthropic` (Ask AI modal, issue 0080): stub interno activo. Promover a `cpp/functions/infra/llm_anthropic` para activar feature real. +- `FN_TQL_DUCKDB`: modo SQL del Ask AI sin soporte en stub. Requiere DuckDB + flag de compilacion. +- `column_specs` TQL roundtrip (Phase 2): actualmente caller-managed. No persisten en TQL emit/apply. Planificado en issue 0081-O. + +## Capability growth log + +v1.1.0 (2026-05-15) — declarative CellRenderer (Badge/Progress/Duration/Icon) via TableInput.column_specs sidecar. Back-compat preservado: apps existentes sin column_specs siguen funcionando sin cambios. + +--- +Promovido desde `cpp/apps/primitives_gallery/playground/tables/data_table.{h,cpp}` — issue 0081-H. diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 8071c0da..084a74cd 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -31,6 +31,13 @@ add_fn_test(test_pie_chart_math test_pie_chart_math.cpp) add_fn_test(test_kpi_card_math test_kpi_card_math.cpp) add_fn_test(test_bar_chart_math test_bar_chart_math.cpp) +# Issue 0081-F — auto_detect_type y compute_column_stats (extraidos del playground tables). +add_fn_test(test_auto_detect_type test_auto_detect_type.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/auto_detect_type.cpp) +add_fn_test(test_compute_column_stats test_compute_column_stats.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/auto_detect_type.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/compute_column_stats.cpp) + # Issue 0045 — tests de la logica pura extraida. add_fn_test(test_sql_parse test_sql_parse.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/sql_parse.cpp) @@ -136,6 +143,43 @@ else() target_link_libraries(test_graph_icons PRIVATE OpenGL::GL) endif() +# --- Issue 0081-B — compute_stage + compute_pipeline (TQL pure logic) ------- +# tql_helpers.cpp added (issue 0081-I): compute_stage.cpp now delegates +# aggregation_alias to tql_helpers to avoid ODR conflict in fn_table_viz lib. +add_fn_test(test_compute_stage test_compute_stage.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/compute_stage.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_helpers.cpp) +add_fn_test(test_compute_pipeline test_compute_pipeline.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/compute_stage.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/compute_pipeline.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_helpers.cpp) + +# --- Issue 0081-E — join_tables: pure multi-key hash join -------------------- +add_fn_test(test_join_tables test_join_tables.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/join_tables.cpp) + +# --- Issue 0081-G — viz_render: dispatcher ImPlot sobre StageOutput --------- +# viz_render.cpp incluye imgui.h e implot.h y linkea contra ambas librerias. +# El test NO inicializa GL ni contexto ImGui — solo ejercita las funciones +# helper publicas (first_numeric_col, first_category_col, extract_numeric, +# extract_category) que son logica pura sobre StageOutput. +# render() requiere ImPlot context vivo: smoke real via primitives_gallery. +add_fn_test(test_viz_render test_viz_render.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/viz_render.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/auto_detect_type.cpp) +target_include_directories(test_viz_render PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../vendor/imgui + ${CMAKE_CURRENT_SOURCE_DIR}/../vendor/implot) +target_link_libraries(test_viz_render PRIVATE imgui implot) + +# --- lua_engine: motor Lua 5.4 sandbox (issue 0081-D) ---------------------- +# lua_engine.cpp incluye lua.h/lualib.h/lauxlib.h — requiere linkar lua54. +# data_table_types.h esta en functions/core/ (ya en el include path de add_fn_test). +add_fn_test(test_lua_engine test_lua_engine.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/lua_engine.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/auto_detect_type.cpp) +target_link_libraries(test_lua_engine PRIVATE lua54) + # --- layout_storage: persistencia de last_active (restore-on-open) --------- # layout_storage.cpp incluye y referencia ImGui::Save/LoadIniSettings*, # por eso necesitamos linkar contra imgui (compilado en el target del root @@ -145,6 +189,73 @@ add_fn_test(test_layout_storage test_layout_storage.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/layout_storage.cpp) target_link_libraries(test_layout_storage PRIVATE SQLite::SQLite3 imgui) +# --- Issue 0081-C — tql_emit + tql_apply (TQL round-trip, pure) ------------ +# tql_helpers.cpp: pure token converters (no Lua, no ImGui). +# tql_emit.cpp: State -> Lua text (no Lua runtime needed). +# tql_apply.cpp: Lua text -> State (uses Lua 5.4 C API directly, not lua_engine). +# Both tests use their own main() (no Catch2) matching fn run dispatch. +add_executable(tql_emit_test + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_emit_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_emit.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_helpers.cpp) +target_include_directories(tql_emit_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../functions + ${CMAKE_CURRENT_SOURCE_DIR}/../framework) +add_test(NAME tql_emit_test COMMAND tql_emit_test) + +add_executable(tql_apply_test + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_apply_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_apply.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_emit.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_helpers.cpp) +target_include_directories(tql_apply_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../functions + ${CMAKE_CURRENT_SOURCE_DIR}/../framework) +target_link_libraries(tql_apply_test PRIVATE lua54) +add_test(NAME tql_apply_test COMMAND tql_apply_test) + +# --- Issue 0081-I — fn_table_viz static lib smoke test --------------------- +# Linker test: verifies that all 9 registry .cpp files in fn_table_viz resolve +# symbols correctly when linked as a static lib. Does NOT call data_table::render +# (requires ImGui context + playground headers). Uses its own main(). +if(TARGET fn_table_viz) + add_executable(test_fn_table_viz_smoke + ${CMAKE_CURRENT_SOURCE_DIR}/test_fn_table_viz_smoke.cpp) + target_include_directories(test_fn_table_viz_smoke PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../functions + ${CMAKE_CURRENT_SOURCE_DIR}/../vendor/imgui + ${CMAKE_CURRENT_SOURCE_DIR}/../vendor/implot) + target_link_libraries(test_fn_table_viz_smoke PRIVATE fn_table_viz) + add_test(NAME test_fn_table_viz_smoke COMMAND test_fn_table_viz_smoke) +endif() + +# --- Issue 0081-N — declarative CellRenderer (Badge/Progress/Duration/Icon) -- +# Smoke + back-compat tests for TableInput.column_specs (v1.1.0). +# Verifies type construction + link resolution; does NOT call render() (ImGui). +if(TARGET fn_table_viz) + add_executable(test_column_specs + ${CMAKE_CURRENT_SOURCE_DIR}/test_column_specs.cpp) + target_include_directories(test_column_specs PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../functions + ${CMAKE_CURRENT_SOURCE_DIR}/../vendor/imgui + ${CMAKE_CURRENT_SOURCE_DIR}/../vendor/implot) + target_link_libraries(test_column_specs PRIVATE fn_table_viz) + add_test(NAME test_column_specs COMMAND test_column_specs) +endif() + +# --- Issue 0081 — tql_to_sql: pure TQL State -> SQL DuckDB emitter ---------- +# Pure logic: no DuckDB linked, no ImGui. Only data_table_types.h + tql_helpers. +add_fn_test(test_tql_to_sql test_tql_to_sql.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_to_sql.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/tql_helpers.cpp) + +# --- Issue 0081 — llm_anthropic: Anthropic API client pure helpers ---------- +# Only tests pure functions (build_request_body, extract_code_block, +# parse_response_text) + call_api via FN_LLM_MOCK_RESPONSE injection. +# Real HTTP roundtrip requires a valid API key (manual_test). +add_fn_test(test_llm_anthropic test_llm_anthropic.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/llm_anthropic.cpp) + # --- Visual golden-image diff (issue 0048) --------------------------------- # El binario primitives_gallery se compila con --capture; el test compara los # PNGs generados con los goldens en cpp/tests/golden/. Si no hay goldens o el diff --git a/cpp/tests/test_column_specs.cpp b/cpp/tests/test_column_specs.cpp new file mode 100644 index 00000000..5cc5c04c --- /dev/null +++ b/cpp/tests/test_column_specs.cpp @@ -0,0 +1,196 @@ +// test_column_specs.cpp — Smoke / back-compat tests for declarative cell renderers. +// Issue 0081-N, v1.1.0. +// +// These tests verify: +// 1. TableInput without column_specs compiles and links (back-compat). +// 2-5. TableInput with Badge/Progress/Duration/Icon column_specs compiles and links. +// +// None of these tests call data_table::render() (requires ImGui context). +// They only verify that the new types are usable and that the symbols from +// fn_table_viz link correctly. +// +// Build: cmake --build cpp/build/linux --target test_column_specs +// Run: ./cpp/build/linux/tests/test_column_specs + +#include "core/data_table_types.h" +#include "viz/data_table.h" + +#include +#include +#include +#include + +using namespace data_table; + +// Shared trivial dataset (3 rows x 4 cols). +static const char* g_cells[] = { + "ok", "0.75", "250", "fn", + "error", "0.20", "3500", "type", + "warn", "1.00", "12000", "fn", +}; +static const std::vector g_headers = {"status", "progress", "duration_ms", "kind"}; +static const std::vector g_types = { + ColumnType::String, ColumnType::Float, ColumnType::Float, ColumnType::String +}; + +// --------------------------------------------------------------------------- +// Test 1: back-compat — TableInput without column_specs. +// --------------------------------------------------------------------------- +static void test_no_column_specs() { + TableInput t; + t.name = "t1"; + t.rows = 3; + t.cols = 4; + t.cells = g_cells; + t.headers = g_headers; + t.types = g_types; + // column_specs intentionally left empty (back-compat: default behavior). + + assert(t.column_specs.empty() && "column_specs must be empty by default"); + + // Verify that render symbol is still linkable (no ImGui context needed + // to take the address; the linker verifies the symbol resolves). + auto* render_fn = &data_table::render; + (void)render_fn; + + std::printf("PASS: test_no_column_specs (back-compat, column_specs empty)\n"); +} + +// --------------------------------------------------------------------------- +// Test 2: Badge renderer — construct TableInput with Badge column_spec. +// --------------------------------------------------------------------------- +static void test_badge_column_spec() { + TableInput t; + t.name = "t2"; + t.rows = 3; + t.cols = 4; + t.cells = g_cells; + t.headers = g_headers; + t.types = g_types; + + // Column 0: Badge renderer mapping ok/error/warn to colors. + ColumnSpec cs_status; + cs_status.id = "status"; + cs_status.renderer = CellRenderer::Badge; + cs_status.badges = { + BadgeRule{"ok", "#22c55e", "OK"}, + BadgeRule{"error", "#ef4444", ""}, // label empty -> use value + BadgeRule{"warn", "#f59e0b", "WARN"}, + }; + + // Remaining columns: default Text. + t.column_specs.resize(4); // default-initialized = CellRenderer::Text + t.column_specs[0] = cs_status; + + assert(t.column_specs.size() == 4); + assert(t.column_specs[0].renderer == CellRenderer::Badge); + assert(t.column_specs[0].badges.size() == 3); + assert(t.column_specs[1].renderer == CellRenderer::Text); + + std::printf("PASS: test_badge_column_spec (3 badge rules, remaining cols Text)\n"); +} + +// --------------------------------------------------------------------------- +// Test 3: Progress renderer. +// --------------------------------------------------------------------------- +static void test_progress_column_spec() { + TableInput t; + t.name = "t3"; + t.rows = 3; + t.cols = 4; + t.cells = g_cells; + t.headers = g_headers; + t.types = g_types; + + ColumnSpec cs_progress; + cs_progress.id = "progress"; + cs_progress.renderer = CellRenderer::Progress; + cs_progress.progress_scale_100 = false; + cs_progress.progress_color_hex = "#3b82f6"; + + t.column_specs.resize(4); + t.column_specs[1] = cs_progress; + + assert(t.column_specs[1].renderer == CellRenderer::Progress); + assert(!t.column_specs[1].progress_scale_100); + assert(t.column_specs[1].progress_color_hex == "#3b82f6"); + + std::printf("PASS: test_progress_column_spec\n"); +} + +// --------------------------------------------------------------------------- +// Test 4: Duration renderer. +// --------------------------------------------------------------------------- +static void test_duration_column_spec() { + TableInput t; + t.name = "t4"; + t.rows = 3; + t.cols = 4; + t.cells = g_cells; + t.headers = g_headers; + t.types = g_types; + + ColumnSpec cs_dur; + cs_dur.id = "duration_ms"; + cs_dur.renderer = CellRenderer::Duration; + cs_dur.duration_warn_ms = 500.0f; + cs_dur.duration_error_ms = 2000.0f; + + t.column_specs.resize(4); + t.column_specs[2] = cs_dur; + + assert(t.column_specs[2].renderer == CellRenderer::Duration); + assert(t.column_specs[2].duration_warn_ms == 500.0f); + assert(t.column_specs[2].duration_error_ms == 2000.0f); + + std::printf("PASS: test_duration_column_spec (warn=500ms error=2000ms)\n"); +} + +// --------------------------------------------------------------------------- +// Test 5: Icon renderer. +// --------------------------------------------------------------------------- +static void test_icon_column_spec() { + TableInput t; + t.name = "t5"; + t.rows = 3; + t.cols = 4; + t.cells = g_cells; + t.headers = g_headers; + t.types = g_types; + + ColumnSpec cs_icon; + cs_icon.id = "kind"; + cs_icon.renderer = CellRenderer::Icon; + cs_icon.icon_map = { + IconMapEntry{"fn", "TI_BOLT", "#3b82f6"}, + IconMapEntry{"type", "TI_DATABASE", ""}, + }; + + t.column_specs.resize(4); + t.column_specs[3] = cs_icon; + + assert(t.column_specs[3].renderer == CellRenderer::Icon); + assert(t.column_specs[3].icon_map.size() == 2); + assert(t.column_specs[3].icon_map[0].value == "fn"); + assert(t.column_specs[3].icon_map[0].icon_name == "TI_BOLT"); + + // Verify render symbol still links with column_specs populated. + auto* render_fn = &data_table::render; + (void)render_fn; + + std::printf("PASS: test_icon_column_spec (2 entries, render symbol links)\n"); +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +int main() { + std::printf("=== test_column_specs ===\n"); + test_no_column_specs(); + test_badge_column_spec(); + test_progress_column_spec(); + test_duration_column_spec(); + test_icon_column_spec(); + std::printf("=== ALL TESTS PASSED (5/5) ===\n"); + return 0; +} diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index e512e534..90155768 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -14,16 +14,23 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | Grupo | N | Que cubre | |---|---|---| -| [bigquery](bigquery.md) | 26 | _(editar — promovido automaticamente)_ | -| [nlp](nlp.md) | 33 | _(editar — promovido automaticamente)_ | -| [docker](docker.md) | 38 | _(editar — promovido automaticamente)_ | -| [android](android.md) | 37 | _(editar — promovido automaticamente)_ | -| [metabase](metabase.md) | 106 | _(editar — promovido automaticamente)_ | +| [registry](registry.md) | 17 | Auditoria y monitorizacion del propio registry: copied-code, uses-functions, unused, proposals, telemetria | +| [systemd](systemd.md) | 14 | Generar, instalar, restart y status de unit files systemd via SSH (deploys a VPS) | +| [ssh](ssh.md) | 19 | Operar hosts remotos via SSH: config, conn, ejecutar comandos, port-forward, deploys con SCP/rsync | +| [deploy](deploy.md) | 21 | Deploy completo Go/C++ a VPS o Windows: Docker+Traefik, systemd, rsync, health checks | +| [mantine](mantine.md) | 63 | Frontend Mantine v9 + @fn_library: theming, layout, formularios, modales, instalacion | +| [bigquery](bigquery.md) | 26 | Operar Google BigQuery via SDK Python: queries, dataset/table CRUD, jobs, schema, exports | +| [nlp](nlp.md) | 33 | Extraccion NLP: PDFs, OCR, chunking, GLiNER/GLiREL, dedup, agregacion de entities/relations | +| [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys | +| [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat | +| [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions | | [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas | | [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) | | [cpp-windows](cpp-windows.md) | 7 | Compilar, desplegar, lanzar y verificar apps C++ en Windows desde WSL2 | | [git](git.md) | 19 | Operaciones git y Gitea: clonar, commit, push/pull, hooks, TBD, webhooks, sync entre PCs | | [playwright](playwright.md) | 6 | E2E browser: launch chromium, login kanban, drag dnd-kit, keyboard sequence, wait predicate, assert class | +| [cpp-tables](tql.md) | 9 | Table Query Language C++ puro: filter, group, agg, sort, join, stats, formulas Lua, round-trip emit/apply | +| [data-table-renderers](data_table_renderers.md) | 1 | API declarativa de cell renderers para data_table: Badge, Progress, Duration, Icon via TableInput.column_specs | ## Como anadir grupo diff --git a/docs/capabilities/data_table_renderers.md b/docs/capabilities/data_table_renderers.md new file mode 100644 index 00000000..738ec043 --- /dev/null +++ b/docs/capabilities/data_table_renderers.md @@ -0,0 +1,127 @@ +# data_table_renderers — declarative cell renderers (v1.1.0) + +Tag: `cpp-tables` (mismo grupo que TQL; los renderers son parte del stack `data_table`). + +Extiende `data_table_cpp_viz` con una API declarativa para renderizar columnas con +Badge, Progress, Duration e Icon **sin escribir ImGui inline**. Activado via el +campo opcional `column_specs` de `TableInput`. Back-compat 100%: apps sin +`column_specs` no necesitan cambios. + +## Tipos nuevos en `data_table_types.h` + +| Tipo | Que es | +|---|---| +| `CellRenderer` | enum class: `Text=0`, `Badge=1`, `Progress=2`, `Duration=3`, `Icon=4` | +| `BadgeRule` | value (exact match) + color_hex + label opcional | +| `IconMapEntry` | value + icon_name (ej. `"TI_BOLT"`) + color_hex opcional | +| `ColumnSpec` | id + renderer + badges / progress fields / duration thresholds / icon_map | +| `TableInput::column_specs` | `std::vector` sidecar opcional (size 0 o == cols) | + +## Ejemplo canonico: Recent Executions (status Badge + duration Duration) + +```cpp +#include "viz/data_table.h" +#include "core/data_table_types.h" + +// --- Datos (owner externo) --- +static const char* g_exec_cells[] = { + "ok", "142", + "error", "3850", + "ok", "72", + "warn", "1100", +}; + +// --- Setup (una vez, en init de la app o antes del loop) --- +data_table::TableInput t; +t.name = "executions"; +t.rows = 4; +t.cols = 2; +t.cells = g_exec_cells; +t.headers = {"status", "duration_ms"}; +t.types = {data_table::ColumnType::String, data_table::ColumnType::Float}; + +// Columna 0: Badge por valor de status +data_table::ColumnSpec cs_status; +cs_status.id = "status"; +cs_status.renderer = data_table::CellRenderer::Badge; +cs_status.badges = { + data_table::BadgeRule{"ok", "#22c55e", "OK"}, + data_table::BadgeRule{"error", "#ef4444", "ERROR"}, + data_table::BadgeRule{"warn", "#f59e0b", "WARN"}, +}; + +// Columna 1: Duration con gradiente verde/amarillo/rojo +data_table::ColumnSpec cs_dur; +cs_dur.id = "duration_ms"; +cs_dur.renderer = data_table::CellRenderer::Duration; +cs_dur.duration_warn_ms = 500.0f; +cs_dur.duration_error_ms = 2000.0f; + +t.column_specs = {cs_status, cs_dur}; + +data_table::State st; // persiste entre frames + +// --- Render loop --- +ImGui::Begin("Executions"); +ImGui::BeginChild("##exec_tbl", ImVec2(-1, -1)); +data_table::render("##exec", {t}, st); +ImGui::EndChild(); +ImGui::End(); +``` + +## Ejemplo: Progress bar + Icon + +```cpp +data_table::ColumnSpec cs_progress; +cs_progress.id = "completion"; +cs_progress.renderer = data_table::CellRenderer::Progress; +cs_progress.progress_scale_100 = true; // cell value es 0..100 +cs_progress.progress_color_hex = "#3b82f6"; // azul; "" -> ImPlot auto + +data_table::ColumnSpec cs_icon; +cs_icon.id = "kind"; +cs_icon.renderer = data_table::CellRenderer::Icon; +cs_icon.icon_map = { + data_table::IconMapEntry{"fn", "TI_BOLT", "#3b82f6"}, + data_table::IconMapEntry{"type", "TI_DATABASE", ""}, + data_table::IconMapEntry{"app", "TI_SETTINGS", "#6b7280"}, +}; + +t.column_specs = {cs_progress, cs_icon}; +``` + +## Iconos soportados en CellRenderer::Icon + +El lookup es una tabla estatica de ~30 nombres frecuentes: + +``` +TI_CHECK TI_X TI_ALERT_CIRCLE TI_CIRCLE_DOT +TI_CLOCK TI_LOADER TI_BAN TI_PLAYER_PLAY +TI_PLAYER_PAUSE TI_PLAYER_STOP TI_DATABASE TI_SETTINGS +TI_USER TI_USERS TI_FILE TI_FOLDER +TI_REFRESH TI_BOLT TI_INFO_CIRCLE TI_ARROW_UP +TI_ARROW_DOWN TI_ARROW_RIGHT TI_ARROW_LEFT TI_DOTS +TI_EYE TI_EYE_OFF TI_EDIT TI_TRASH +TI_COPY TI_EXTERNAL_LINK +``` + +Si el `icon_name` no esta en la tabla, la celda se renderiza como texto plano. + +## Fronteras + +- **Solo Column 0..N posicional**: `column_specs[i]` aplica a la columna en posicion `i` del `TableInput` original. No se mapea por nombre (Phase 2). +- **No persiste en TQL**: `column_specs` son responsabilidad del caller — se construyen cada frame o en el setup. `tql_emit`/`tql_apply` no los tocan (Phase 2 planificado). +- **No implementa Button/TextInput/Custom** (Phase 2-3 separados). +- **Stage N (agregado)**: los renderers se aplican por posicion de columna del output agregado — si el breakout cambia el numero de columnas, revisar los indices. + +## Gotchas + +- `column_specs.size()` debe ser 0 (sin specs) o igual a `t.cols`. Mezcla de tamaños puede causar out-of-bounds silencioso (el render hace `c < column_specs.size()` guard, pero es mejor ser expliciito). +- `hex_to_imcolor` acepta `"#rrggbb"` o `"rrggbb"`. Alpha siempre 1.0. Sin soporte para `rgba`. +- El ColorRule existente de State (`st.color_rules`) sigue funcionando — ambos sistemas coexisten. Si hay conflicto, `column_specs` toma prioridad para el contenido de la celda; `color_rules` pinta el fondo via `TableSetBgColor`. +- En el renderer Badge el `Selectable` con background coloreado consume el item para hover/click — la seleccion de rango con drag puede verse afectada visualmente en columnas Badge. + +## Notas + +- Tests: `cpp/tests/test_column_specs.cpp` (5 tests: 1 back-compat + 4 renderer types). Smoke/linker; no requieren ImGui context. +- TQL roundtrip pendiente: issue 0081-O (Phase 2). diff --git a/types/core/badge_rule.md b/types/core/badge_rule.md new file mode 100644 index 00000000..73da9030 --- /dev/null +++ b/types/core/badge_rule.md @@ -0,0 +1,22 @@ +--- +name: BadgeRule +lang: cpp +domain: core +version: "1.0.0" +algebraic: product +definition: | + struct BadgeRule { + std::string value; + std::string color_hex; + std::string label; + }; +description: "Regla de badge para CellRenderer::Badge. Mapea un valor exacto de celda a un color hex y un label visual opcional. Si label vacio, se usa value. Issue 0081-N." +tags: [tables, tql, types, cpp-tables] +uses_types: [] +file_path: "cpp/functions/core/data_table_types.h" +--- + +## Notas + +`color_hex` acepta `"#rrggbb"` o `"rrggbb"`. Sin alpha — siempre opaco. +`value` es exact match case-sensitive. Sin wildcards ni regex en Phase 1. diff --git a/types/core/cell_renderer.md b/types/core/cell_renderer.md new file mode 100644 index 00000000..7bc8d702 --- /dev/null +++ b/types/core/cell_renderer.md @@ -0,0 +1,24 @@ +--- +name: CellRenderer +lang: cpp +domain: core +version: "1.0.0" +algebraic: sum +definition: | + enum class CellRenderer : uint8_t { + Text = 0, + Badge = 1, + Progress = 2, + Duration = 3, + Icon = 4, + }; +description: "Enum declarativo de modo de render por columna para data_table. Text = comportamiento actual (back-compat). Badge/Progress/Duration/Icon activan renderizado visual via TableInput.column_specs. Issue 0081-N." +tags: [tables, tql, types, cpp-tables] +uses_types: [] +file_path: "cpp/functions/core/data_table_types.h" +--- + +## Notas + +Sum type (enum). Valores futuros reservados: Button=5, TextInput=6, Custom=7. +El renderer se usa via `ColumnSpec.renderer` dentro de `TableInput.column_specs`. diff --git a/types/core/column_spec.md b/types/core/column_spec.md new file mode 100644 index 00000000..d263ee76 --- /dev/null +++ b/types/core/column_spec.md @@ -0,0 +1,32 @@ +--- +name: ColumnSpec +lang: cpp +domain: core +version: "1.0.0" +algebraic: product +definition: | + struct ColumnSpec { + std::string id; + CellRenderer renderer = CellRenderer::Text; + std::vector badges; + bool progress_scale_100 = false; + std::string progress_color_hex; + float duration_warn_ms = 1000.0f; + float duration_error_ms = 5000.0f; + std::vector icon_map; + }; +description: "Spec declarativa de render para una columna de data_table. Indexada por posicion en TableInput.column_specs. Activa Badge/Progress/Duration/Icon sin escribir ImGui inline. Issue 0081-N." +tags: [tables, tql, types, cpp-tables] +uses_types: + - CellRenderer_cpp_core + - BadgeRule_cpp_core + - IconMapEntry_cpp_core +file_path: "cpp/functions/core/data_table_types.h" +--- + +## Notas + +`id` es un identificador estable para uso futuro en TQL roundtrip (Phase 2). +En Phase 1 no se serializa — el caller construye `column_specs` cada frame. +Los campos de renderer no activo se ignoran: si `renderer=Badge`, solo se leen +`badges`; si `renderer=Duration`, solo `duration_warn_ms` y `duration_error_ms`. diff --git a/types/core/icon_map_entry.md b/types/core/icon_map_entry.md new file mode 100644 index 00000000..74bf81a8 --- /dev/null +++ b/types/core/icon_map_entry.md @@ -0,0 +1,24 @@ +--- +name: IconMapEntry +lang: cpp +domain: core +version: "1.0.0" +algebraic: product +definition: | + struct IconMapEntry { + std::string value; + std::string icon_name; + std::string color_hex; + }; +description: "Entrada de mapa de iconos para CellRenderer::Icon. Mapea un valor de celda a un nombre de icono Tabler (ej. 'TI_BOLT') y un color opcional. Issue 0081-N." +tags: [tables, tql, types, cpp-tables] +uses_types: [] +file_path: "cpp/functions/core/data_table_types.h" +--- + +## Notas + +`icon_name` debe coincidir con un macro de `cpp/functions/core/icons_tabler.h`. +Lookup estatico en `data_table.cpp` cubre ~30 iconos frecuentes. Si el nombre +no esta en la tabla, la celda se renderiza como texto plano. +`color_hex` vacio -> color de texto por defecto.