From 951a77ec7f0d43cf00ab312dd92141ba539ee7ef Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 14:49:56 +0200 Subject: [PATCH 01/18] close issue 0081: tables promoted to registry + fn doctor cpp-apps BeginTable check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/TQL.md: añadidas secciones joins, views, main_source, 24 viz tokens completos (extraidos de tql_helpers.cpp), color_rules, fn.* builtins completos (20 funciones), funciones bloqueadas del sandbox, tabla de estado de implementacion actualizada. Nota al pie referencia los 129 checks roundtrip (41 emit + 88 apply). - functions/infra/audit_cpp_apps.go: añadida AuditCppTableMigration() que escanea .cpp de cada app imgui buscando ImGui::BeginTable; status CANDIDATE/MIXED/clean segun si usa data_table_cpp_viz en uses_functions. - cmd/fn/doctor.go: fn doctor cpp-apps ahora incluye seccion BeginTable migration con tabwriter CANDIDATE/MIXED; --json produce {conformance, table_migration}. doctorAll incluye cpp_table_migration en el mapa JSON. - .claude/rules/fn_doctor.md: tabla de subcomandos y acciones complementarias actualizadas con el nuevo check. - dev/issues/0081 movido a completed/ con status done y notas de deuda documentadas. Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/fn_doctor.md | 13 +- cmd/fn/doctor.go | 43 ++- .../0081-tables-promote-registry.md | 14 +- docs/TQL.md | 309 ++++++++++++++++-- functions/infra/audit_cpp_apps.go | 117 +++++++ 5 files changed, 470 insertions(+), 26 deletions(-) rename dev/issues/{ => completed}/0081-tables-promote-registry.md (88%) diff --git a/.claude/rules/fn_doctor.md b/.claude/rules/fn_doctor.md index 38251bd6..a68171f4 100644 --- a/.claude/rules/fn_doctor.md +++ b/.claude/rules/fn_doctor.md @@ -20,10 +20,18 @@ fn doctor sync # Solo drift pc_locations BD vs disco local fn doctor uses-functions # Solo audit imports reales vs uses_functions fn doctor unused # Solo funciones huerfanas del registry fn doctor cpp-apps # Conformidad C++ con cpp/PATTERNS.md (cfg.about/log, no app_menubar manual, no DockSpace duplicado) + # + check BeginTable inline: CANDIDATE (no migrado) / MIXED (parcial) / silencio (limpio) fn doctor --json # Salida JSON (cualquier subcomando) — para agentes/scripts ``` +`fn doctor cpp-apps` produce dos secciones: +1. Conformance (cfg.about/log, fn::run_app, menubar, DockSpace) — una fila por app imgui. +2. BeginTable migration (issue 0081) — solo apps con `ImGui::BeginTable` inline: + - `CANDIDATE`: N tablas inline sin `data_table_cpp_viz` en uses_functions. Considerar migracion. + - `MIXED`: N tablas inline con `data_table_cpp_viz` ya declarado. Migracion parcial OK. + - silencio: 0 BeginTable inline (limpio o completamente migrado). + ### Mapeo subcomando → funcion del registry | Subcomando | Funcion | @@ -33,7 +41,8 @@ fn doctor --json # Salida JSON (cualquier subcomando) — para agentes | `sync` | `pc_locations_drift_go_infra` | | `uses-functions` | `audit_uses_functions_go_infra` | | `unused` | `find_unused_functions_go_infra` | -| `cpp-apps` | `audit_cpp_apps_go_infra` | +| `cpp-apps` (conformance) | `audit_cpp_apps_go_infra` | +| `cpp-apps` (table migration) | `audit_cpp_table_migration_go_infra` (inline en `audit_cpp_apps.go`) | Cada subcomando es un wrapper fino. Toda la logica vive en la funcion. Si quieres usar la salida en otro programa Go, importa la funcion directamente. @@ -64,6 +73,8 @@ Texto humano por defecto (tabwriter). `--json` produce array/objeto serializable | `manual_DockSpaceOverViewport_*` | Borrar la llamada o setear `cfg.auto_dockspace = false` si la app gestiona docking propio | | `missing_cfg_about` / `missing_cfg_log` | Anadir `cfg.about = {...}` / `cfg.log = {".log", 1}` antes de `fn::run_app` | | `app.md_missing_*` | Regenerar via plantilla del scaffolder (`/new-cpp-app`) o anadir campos a mano | +| cpp-apps BeginTable `CANDIDATE` | App tiene N `ImGui::BeginTable` sin migrar. Abrir rama TBD, reemplazar tablas por `data_table::render()` via `fn_table_viz`, añadir `data_table_cpp_viz` a `uses_functions` en `app.md` | +| cpp-apps BeginTable `MIXED` | Migracion parcial en curso. Continuar wave por wave hasta que no queden BeginTable inline | | Backup viejo | `backup_all_bash_pipelines ~/backups/fn_registry` | ### Para agentes diff --git a/cmd/fn/doctor.go b/cmd/fn/doctor.go index f2413ce0..72efc756 100644 --- a/cmd/fn/doctor.go +++ b/cmd/fn/doctor.go @@ -123,6 +123,11 @@ func doctorAll(root string, jsonOut bool) { } else { all["cpp_apps_error"] = err.Error() } + if v, err := infra.AuditCppTableMigration(root); err == nil { + all["cpp_table_migration"] = v + } else { + all["cpp_table_migration_error"] = err.Error() + } if v, err := infra.AuditMlEnv(root); err == nil { all["ml"] = v } else { @@ -168,10 +173,21 @@ func doctorCppApps(root string, jsonOut bool) { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } + tableAudits, err2 := infra.AuditCppTableMigration(root) + if err2 != nil { + fmt.Fprintf(os.Stderr, "warning: table migration audit failed: %v\n", err2) + tableAudits = nil + } + if jsonOut { - emit(audits) + emit(map[string]any{ + "conformance": audits, + "table_migration": tableAudits, + }) return } + + // Conformance section. bad := 0 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "STATUS\tAPP\tISSUES") @@ -187,6 +203,31 @@ func doctorCppApps(root string, jsonOut bool) { } w.Flush() fmt.Printf("\n%d/%d C++ apps conform.\n", len(audits)-bad, len(audits)) + + // BeginTable migration section. + if len(tableAudits) == 0 { + return + } + hasMigrationNotes := false + for _, t := range tableAudits { + if t.Status != "clean" { + hasMigrationNotes = true + break + } + } + if !hasMigrationNotes { + return + } + fmt.Println("\n--- BeginTable migration (issue 0081) ---") + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "STATUS\tAPP\tTABLES\tMESSAGE") + for _, t := range tableAudits { + if t.Status == "clean" { + continue + } + fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", strings.ToUpper(t.Status), t.AppID, t.BeginTableCount, t.Message) + } + tw.Flush() } func doctorArtefacts(root string, jsonOut bool) { diff --git a/dev/issues/0081-tables-promote-registry.md b/dev/issues/completed/0081-tables-promote-registry.md similarity index 88% rename from dev/issues/0081-tables-promote-registry.md rename to dev/issues/completed/0081-tables-promote-registry.md index 2dc45568..3d65d9c2 100644 --- a/dev/issues/0081-tables-promote-registry.md +++ b/dev/issues/completed/0081-tables-promote-registry.md @@ -1,16 +1,22 @@ --- id: 0081 title: tables playground — promote a registry + migrar apps C++ (fase 12) -status: partial +status: done priority: high created: 2026-05-12 -updated: 2026-05-13 +updated: 2026-05-15 notes: | 0081-A DONE: 20 types extraidos a cpp/functions/core/data_table_types.h con .md por type (17 core + 3 viz). Playground includes via "core/data_table_types.h", no duplicacion. 603 tests pass, e2e linux+windows OK. - 0081-B..L PENDING: extraer functions (compute_stage, tql_emit/apply, lua_engine, tql_to_sql, - join_tables, viz_render, data_table) + fn_table_viz lib + migrar 5 apps. + 0081-B..L DONE (2026-05-15): 10 funciones registry (8 core + 2 viz), 1 lib fn_table_viz, + 3 apps migradas (chart_demo no aplica, graph_explorer parcial 1/9, registry_dashboard parcial 8/12), + fn doctor cpp-apps check anadido (BeginTable inline detection: CANDIDATE/MIXED), + docs/TQL.md actualizado con joins, views, main_source, 24 viz tokens, color_rules, + derived columns, fn.* sandbox completo (20 builtins), funciones bloqueadas. + Deuda: sqlite_api + deploy_server NO migrados (Go apps, requieren TS table system aparte); + graph_explorer + registry_dashboard + otras apps C++ marcadas CANDIDATE por fn doctor + (migrar en waves futuras con rama TBD dedicada por app). related_components: [cpp/apps/primitives_gallery/playground/tables, cpp/functions, fn_framework] --- diff --git a/docs/TQL.md b/docs/TQL.md index fc56afc4..73875309 100644 --- a/docs/TQL.md +++ b/docs/TQL.md @@ -58,7 +58,7 @@ Independiente de los datos. La misma query puede renderizarse de N formas con un ### Implicaciones para TQL -TQL adopta esa separacion: `stages` (data) + `display` + `columns` (viz). Mismo patron, sintaxis Lua. +TQL adopta esa separacion: `stages` (data) + `display` + `columns` (viz) + `views` (paneles adicionales). Mismo patron, sintaxis Lua. Cuando un boton futuro "Add visualization" se construya, anade un nuevo `display` + viz settings a una query existente sin tocar `stages`. Asi tendremos M visualizaciones (table, bar, line, scatter) sobre los mismos datos transformados. @@ -103,6 +103,10 @@ input_cells (raw dataset) return { version = 1, display = "table", + main_source = "functions", -- opcional: nombre de la fuente principal + + -- JOINS: unir tablas adicionales antes de stage 0 + joins = { ... }, -- opcional -- DATA: pipeline de transformacion stages = { @@ -129,9 +133,14 @@ return { color_rules = { {equals = "0.0", color = "#e08060"} }}, {name = "internal", type = "string", visible = false, order = 3}, }, - visualization_settings = { - -- Futuro: opciones especificas del display (chart axes, paleta, stack, etc.) + + -- VIEWS: paneles de visualizacion (index 1 = principal, resto = extras) + views = { + {display = "table"}, + {display = "bar", x_col = "lang", y_cols = {"count"}}, }, + + visualization_settings = {}, } ``` @@ -139,6 +148,57 @@ return { --- +## `main_source` + +Campo de cadena opcional. Identifica el nombre de la tabla/fuente principal del dataset. Usado por `tql_to_sql` para generar el `FROM "main_source"` correcto en el SQL emitido. Si esta vacio, el motor usa la tabla por defecto del contexto. + +```lua +main_source = "functions" +``` + +En el SQL emitido: `FROM "functions"`. Util cuando la app expone multiples tablas y el agente necesita especificar explicitamente cual es la base del query. + +--- + +## `joins` + +Lista de joins que se aplican antes de stage 0. Los campos de las tablas unidas se añaden como columnas adicionales accesibles en todos los stages. + +```lua +joins = { + { + alias = "t", -- prefijo para sus columnas ("t.field") + source = "types", -- nombre de la tabla a unir + strategy = "left", -- "left" | "inner" | "right" | "full" + on = {{"id", "t.id"}}, -- pares {col_izq, col_der} + fields = {"t.algebraic", "t.description"}, -- cols a incluir (opcional) + }, + { + alias = "u", + source = "unit_tests", + strategy = "inner", + on = {{"id", "u.function_id"}, {"lang", "u.lang"}}, -- multi-key + }, +} +``` + +**Estrategias:** + +| Token | Semantica SQL | +|---|---| +| `"left"` | `LEFT OUTER JOIN` — todas las filas de la izq, nulls donde no hay match | +| `"inner"` | `INNER JOIN` — solo filas con match en ambas tablas | +| `"right"` | `RIGHT OUTER JOIN` — todas las filas de la der | +| `"full"` | `FULL OUTER JOIN` — todas las filas de ambas tablas | + +Default si `strategy` se omite: `"left"`. + +**Campos tras el join:** accesibles como `"alias.field"` (ej. `"t.algebraic"`) en filters, breakouts, aggregations y expressions. Si `fields` se omite, se incluyen todas las columnas de la tabla unida con prefijo alias. + +**Join multi-key:** `on` es lista de pares; se traduce a `ON l.k1 = r.k1 AND l.k2 = r.k2`. + +--- + ## `filter` Lista de predicados. Multiples filters se combinan con AND implicito. @@ -191,6 +251,15 @@ breakout = { "lang", "domain" } Cada combinacion unica de valores `(lang, domain)` produce una fila en el output. Si `breakout` esta vacio pero hay `aggregation`, todo el dataset se reduce a UNA sola fila. +**Breakout con granularidad de fecha** — sufijo `:granularity` en el nombre de la col: + +```lua +breakout = { "created_at:month", "lang" } +-- equivale a GROUP BY date_trunc('month', created_at), lang +``` + +Granularidades disponibles: `year`, `month`, `week`, `day`, `hour`. + **Disponible solo en stages >= 1.** --- @@ -274,13 +343,97 @@ columns = { **Cols que no aparecen en `columns`**: mantienen su estado UI actual (visible, posicion natural). +### `color_rules` + +Reglas de color condicional por valor exacto. Se aplican al renderizar cada celda de la columna: si el valor de la celda es igual a `equals`, la celda se colorea con `color`. + +```lua +color_rules = { + {equals = "go", color = "#86b56b"}, -- verde para Go + {equals = "py", color = "#6b8eb5"}, -- azul para Python + {equals = "bash", color = "#b58f6b"}, -- naranja para Bash +} +``` + +- Solo soporta igualdad exacta (string match). Para rangos numericos, usa una expression que produzca una etiqueta ("high"/"low") y aplica color_rules sobre esa columna derivada. +- Multiples reglas se evaluan en orden; la primera que hace match gana. +- Si ningun match: color por defecto del tema. + +--- + ## `display` -Tipo de visualizacion. v1 solo `"table"`. Futuro: `"bar"`, `"line"`, `"scatter"`, `"pie"`, `"scalar"`, `"area"`, `"pivot"`. Default: `"table"`. +Tipo de visualizacion del panel principal. Default: `"table"`. + +**Tokens validos (extraidos de `tql_helpers.cpp`):** + +| Token | Tipo de chart | +|---|---| +| `"table"` | Tabla de datos (default) | +| `"bar"` | Barras horizontales | +| `"column"` | Barras verticales | +| `"grouped_bar"` | Barras agrupadas por categoria | +| `"stacked_bar"` | Barras apiladas | +| `"line"` | Lineas | +| `"area"` | Area rellena | +| `"stairs"` | Escalera (step function) | +| `"scatter"` | Dispersion XY | +| `"bubble"` | Dispersion XY con tamano variable | +| `"histogram"` | Histograma 1D | +| `"hist2d"` | Histograma 2D | +| `"heatmap"` | Mapa de calor | +| `"boxplot"` | Caja y bigotes | +| `"stem"` | Stem plot | +| `"errorbars"` | Barras de error | +| `"pie"` | Sectores (pie chart) | +| `"donut"` | Donut | +| `"funnel"` | Embudo | +| `"waterfall"` | Cascada | +| `"kpi"` | Metrica KPI (numero grande) | +| `"kpi_grid"` | Grid de KPIs | +| `"candlestick"` | Velas (OHLC) | +| `"radar"` | Radar / spider | + +Token invalido: `tql_apply` genera warning `"unknown display"` y cae a `"table"`. + +--- + +## `views` + +Array de paneles de visualizacion. El indice 1 es el panel principal (equivale al `display` + `viz_config` del State); el resto son paneles extra que se muestran junto a la tabla. + +```lua +views = { + -- Panel 0 (principal) + {display = "bar", x_col = "lang", y_cols = {"count"}, color = "#86b56b"}, + -- Panel 1 (extra) + {display = "pie", cat_col = "lang", y_cols = {"sum_size_kb"}, show_legend = true}, +} +``` + +**Campos por panel:** + +| Campo | Tipo | Para que | +|---|---|---| +| `display` | string | Token de tipo de chart (ver tabla `display`) | +| `x_col` | string | Columna para eje X (bar, column, line, area, scatter, bubble, etc.) | +| `y_cols` | `{string,...}` | Columnas para eje Y. Multiple = multiple series | +| `cat_col` | string | Columna de categorias (pie, donut, funnel, radar) | +| `size_col` | string | Columna para tamano del burbuja (bubble) | +| `color` | string | Color primario `"#rrggbb"`. Sirve para series unicas o acento | +| `hist_bins` | int | Numero de bins para histogram / hist2d | +| `pie_radius` | float | Radio del donut interior (donut, 0.0 = pie solido) | +| `show_legend` | bool | Mostrar leyenda. Default `true` | +| `show_markers` | bool | Puntos en lineas/area. Default `false` | +| `locked` | bool | Panel fijo — el usuario no puede cerrarlo ni cambiar tipo | + +Si `views` se omite, el emit lo serializa con un panel minimo que replica `state.display`. + +--- ## `visualization_settings` -Reservado para configuracion especifica por tipo de display. v1 vacio. Futuro: +Reservado para configuracion especifica por tipo de display. v1 siempre vacio (`{}`). Emitido por `tql_emit` para mantener el round-trip completo. Futuro: ```lua visualization_settings = { @@ -293,6 +446,8 @@ visualization_settings = { Sintaxis Metabase: las keys con `.` van entre brackets `[]`. +--- + ## `sort` Lista de clauses. Multi-sort por orden de aparicion (primera = primaria). @@ -318,6 +473,8 @@ Pregunta: "Para las funciones puras con cobertura >= 80%, agrupa por lenguaje y ```lua return { + version = 1, + display = "table", stages = { -- Stage 0: Raw + filter { @@ -350,6 +507,44 @@ return { --- +## Ejemplo con join + views + +```lua +return { + version = 1, + display = "bar", + main_source = "functions", + joins = { + { + alias = "u", + source = "unit_tests", + strategy = "left", + on = {{"id", "u.function_id"}}, + fields = {"u.name"}, + }, + }, + stages = { + { filter = {{"=", "lang", "go"}} }, + { + breakout = {"domain"}, + aggregation = {{"count"}, {"distinct", "id"}}, + sort = {{"desc", "count"}}, + }, + }, + columns = { + {name = "domain", type = "string", visible = true, order = 1}, + {name = "count", type = "int", visible = true, order = 2}, + }, + views = { + {display = "bar", x_col = "domain", y_cols = {"count"}, show_legend = false}, + {display = "donut", cat_col = "domain", y_cols = {"count"}, show_legend = true}, + }, + visualization_settings = {}, +} +``` + +--- + ## Drill-down (semantica) Si el usuario interactua con una celda agrupada del stage N, hace **drill-down**: @@ -387,8 +582,7 @@ Las strings dentro de `expressions` siguen el mini-DSL Lua de columnas custom. R - Type-aware: cell de col Int/Float llega como number; Bool como boolean; resto como string. Vacia = nil. - UTF-8 ok en nombres `[año]`. - Comentarios `--` y `--[[ ]]` respetados. -- Builtins disponibles via `fn.*`: `upper, lower, length, substring, contains, starts_with, ends_with, replace, trim, concat, to_number, to_string, to_bool, is_null, is_empty, coalesce, parse_date, year, month, day`. -- Sandbox: sin `io`, `require`, `dofile`, `loadfile`, `load`, `package`, `debug`. `os` recortado a `date/time/difftime/clock`. +- Nombres de cols con espacios y puntos soportados en brackets: `[col con espacio]`, `[alias.field]`. Ejemplos: @@ -397,21 +591,73 @@ Ejemplos: fn.concat([lang], ":", [domain]) -- string compose if [coverage_pct] >= 90 then "well" else "low" end fn.year([updated_at]) -- date helper +fn.coalesce([error_type], "none") -- null handling ``` --- +## Funciones Lua disponibles (`fn.*`) + +El sandbox expone estas funciones via la tabla global `fn`. Registradas en `lua_engine.cpp::register_builtins`: + +| Funcion | Firma | Que hace | +|---|---|---| +| `fn.upper(s)` | string -> string | Convierte a mayusculas (ASCII) | +| `fn.lower(s)` | string -> string | Convierte a minusculas (ASCII) | +| `fn.length(s)` | string -> int | Longitud en bytes (`strlen`); nil -> 0 | +| `fn.substring(s, start [, len])` | string, int[, int] -> string | Subcadena 1-based; len omitido = hasta el final | +| `fn.contains(haystack, needle)` | string, string -> bool | True si needle aparece en haystack | +| `fn.starts_with(s, prefix)` | string, string -> bool | True si s empieza por prefix | +| `fn.ends_with(s, suffix)` | string, string -> bool | True si s termina por suffix | +| `fn.replace(s, find, repl)` | string, string, string -> string | Reemplaza todas las ocurrencias de find por repl | +| `fn.trim(s)` | string -> string | Elimina espacios/tabs/newlines del inicio y fin | +| `fn.concat(...)` | vararg -> string | Concatena N argumentos como string | +| `fn.to_number(s)` | string -> number\|nil | Parsea a numero; nil si no parseable | +| `fn.to_string(x)` | any -> string | Convierte a string (usa `luaL_tolstring`) | +| `fn.to_bool(x)` | any -> bool | True si `"true"` o `"1"` | +| `fn.is_null(x)` | any -> bool | True si x es nil | +| `fn.is_empty(x)` | any -> bool | True si x es nil o string vacia | +| `fn.coalesce(...)` | vararg -> any | Devuelve el primer argumento no-nil | +| `fn.parse_date(s)` | string -> table\|nil | Parsea `"YYYY-MM-DD"` -> `{year, month, day}` | +| `fn.year(s)` | string -> int\|nil | Extrae el año de `"YYYY-..."` | +| `fn.month(s)` | string -> int\|nil | Extrae el mes de `"YYYY-MM-..."` | +| `fn.day(s)` | string -> int\|nil | Extrae el dia de `"YYYY-MM-DD"` | + +Ademas, las librerias Lua estandar `string`, `table`, `math`, `os` (recortado) estan disponibles. + +--- + +## Sandbox — funciones bloqueadas + +El engine aplica el sandbox via `lua_engine.cpp::apply_sandbox`. Globals eliminados: + +| Global | Por que bloqueado | +|---|---| +| `io` | I/O de archivos y stdin/stdout | +| `require` | Carga de modulos externos | +| `loadfile` | Ejecucion de archivos Lua arbitrarios | +| `dofile` | Idem | +| `load` | Compilacion y ejecucion de strings arbitrarias | +| `package` | Sistema de paquetes Lua | +| `debug` | Introspection de call stack / upvalues | + +`os` se sustituye por una version recortada que solo expone: `os.date`, `os.time`, `os.difftime`, `os.clock`. El resto de `os` (ejecutar comandos, salir, setenv, etc.) se elimina. + +Las formulas de expresiones se compilan con `luaL_loadbufferx(..., "t")` — el flag `"t"` rechaza bytecode precompilado (solo acepta texto source). + +--- + ## Restricciones v1 | No soportado | Workaround | |---|---| -| Joins entre tablas | Pre-procesar fuera del registry. | -| Subqueries SQL | Usar stages encadenados (modelo equivalente). | | `HAVING` post-aggregation | Stage siguiente con `filter` sobre cols agregadas. | | `LIMIT` | TBD — añadir como `limit = N` en stage v2. | | Window functions | TBD. | | Custom aggregation Lua | TBD — `{"lua", "col", ""}`. | | Alias custom en aggregation v1 | Crear expression post-grupo. | +| color_rules con rangos numericos | Usar expression que emita etiquetas; aplicar color_rules sobre la etiqueta. | +| Multiples fuentes sin join | Declarar cada fuente adicional en `joins`. | --- @@ -423,11 +669,21 @@ Cuando expongas TQL a un LLM, dale este preambulo: You output TQL — a Lua table that describes a table transformation. Format: return { + version = 1, + display = "table", -- table|bar|column|grouped_bar|stacked_bar|line|area|stairs|scatter| + -- bubble|histogram|hist2d|heatmap|boxplot|stem|errorbars| + -- pie|donut|funnel|waterfall|kpi|kpi_grid|candlestick|radar + main_source = "...", -- optional: name of main table/source + joins = { ... }, -- optional: join additional tables stages = { { filter = {...}, expressions = {...}, sort = {...} }, -- Stage 0 (Raw) { filter = {...}, breakout = {...}, aggregation = {...}, sort = {...} }, -- Stage 1+ ... - } + }, + views = { + {display="...", x_col="...", y_cols={...}, cat_col="...", color="...", ...}, -- panel 0 = main + ... -- extra panels + }, } Rules: @@ -437,6 +693,9 @@ Rules: Available fns: count, sum, avg, min, max, distinct, stddev, median, p25, p75, p90, p99, percentile. - Sort: {{"desc", "col"}, ...}. Multi-sort por orden de la lista. - Expressions value es una expresion Lua. Acceso a cols via [col_name]. +- Joins: alias + source + strategy (left/inner/right/full) + on pairs + optional fields list. +- Views: array de paneles, index 1 = principal. display token from the list above. +- color_rules: [{equals="val", color="#rrggbb"}, ...] dentro de cada entry de columns. The available columns of the current input table are: . The available column types: . @@ -483,19 +742,25 @@ StageOutput compute_stage(const char* const* in_cells, int in_rows, int in_cols, | Feature | Status | |---|---| | `Stage` + `Aggregation` types | done | -| `compute_stage` (filter + group + agg + sort) | done (Phase 1) | +| `compute_stage` (filter + group + agg + sort) | done | | Todas las aggregations (count..percentile) | done | | `aggregation_alias` / `aggregation_type` | done | | Multi-sort por stage | done | -| Tests E2E logica | done (37 checks) | -| `tql_emit` / `tql_apply` (Lua round-trip) | Phase 2 (pendiente) | -| State refactor a `vector` | Phase 3 (pendiente) | -| UI breadcrumb stages + chips por stage | Phase 3 (pendiente) | -| Drill-down interactivo | Phase 3 (pendiente) | -| Show TQL / Apply TQL modals | Phase 2 | -| Multi-sort drag-reorder | Phase 4 | - -Ver `cpp/apps/primitives_gallery/playground/tables/` para la implementacion del playground. +| Tests E2E logica | done (129 checks en tql_emit_test + tql_apply_test) | +| `tql_emit` / `tql_apply` (Lua round-trip) | done | +| `views` (paneles de visualizacion) | done | +| `main_source` | done | +| `joins` (left/inner/right/full, multi-key, fields) | done | +| `color_rules` por columna | done | +| `breakout` con granularidad de fecha | done | +| Lua sandbox (`fn.*` builtins, sin io/require/load) | done | +| 24 tipos de viz (table, bar, column, pie, donut...) | done | +| `tql_to_sql` (SQL DuckDB emit) | done (issue 0080) | +| State refactor a `vector` | done | +| UI breadcrumb stages + chips por stage | done | +| Drill-down interactivo | done | +| Show TQL / Apply TQL modals | done | +| Multi-sort drag-reorder | done | --- @@ -580,3 +845,7 @@ SQL transpile error en derived col 'fullname': - **Agente flow:** TQL default. SQL solo si app linko DuckDB. UI Ask AI muestra toggle SQL solo cuando disponible. Ver issue 0080 + `tql_to_sql.{h,cpp}` para implementacion. + +--- + +*Generado a partir de los tests roundtrip en `cpp/functions/core/tql_emit_test.cpp` y `cpp/functions/core/tql_apply_test.cpp` — 129 checks (41 emit + 88 apply) en verde garantizan compatibilidad del round-trip State <-> Lua.* diff --git a/functions/infra/audit_cpp_apps.go b/functions/infra/audit_cpp_apps.go index 2c99263b..59dc1748 100644 --- a/functions/infra/audit_cpp_apps.go +++ b/functions/infra/audit_cpp_apps.go @@ -157,3 +157,120 @@ func cppHasAutoDockspaceFalse(src string) bool { return strings.Contains(src, "auto_dockspace = false") || strings.Contains(src, "auto_dockspace=false") } + +// CppTableMigrationAudit holds the BeginTable migration status for a single C++ app. +type CppTableMigrationAudit struct { + AppID string `json:"app_id"` + DirPath string `json:"dir_path"` + BeginTableCount int `json:"begin_table_count"` + UsesDataTableViz bool `json:"uses_data_table_viz"` + Status string `json:"status"` // "clean", "candidate", "mixed" + Message string `json:"message"` +} + +// AuditCppTableMigration scans C++ apps (imgui framework) for inline +// ImGui::BeginTable calls and checks whether the app already declares +// data_table_cpp_viz in its uses_functions. +// +// Status values: +// - "clean" — no ImGui::BeginTable found (fully migrated or never had tables) +// - "mixed" — has ImGui::BeginTable AND declares data_table_cpp_viz +// (partial migration OK, wave N in progress) +// - "candidate" — has ImGui::BeginTable but does NOT declare data_table_cpp_viz +// (migration not started; consider data_table_cpp_viz, issue 0081) +func AuditCppTableMigration(registryRoot string) ([]CppTableMigrationAudit, error) { + dbPath := filepath.Join(registryRoot, "registry.db") + dsn := fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on", dbPath) + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("audit_cpp_table_migration: open db: %w", err) + } + defer db.Close() + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("audit_cpp_table_migration: ping db: %w", err) + } + + rows, err := db.Query(`SELECT id, dir_path, COALESCE(framework,''), COALESCE(uses_functions,'') FROM apps WHERE lang = 'cpp' ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("audit_cpp_table_migration: query apps: %w", err) + } + defer rows.Close() + + var results []CppTableMigrationAudit + for rows.Next() { + var id, dir, framework, usesFunctions string + if err := rows.Scan(&id, &dir, &framework, &usesFunctions); err != nil { + continue + } + if framework != "imgui" { + continue + } + + absDir := dir + if !filepath.IsAbs(absDir) { + absDir = filepath.Join(registryRoot, dir) + } + if _, err := os.Stat(absDir); os.IsNotExist(err) { + continue // directory_missing already reported by AuditCppApps + } + + // Count ImGui::BeginTable occurrences across all .cpp files (excluding tests/). + count := 0 + err := filepath.WalkDir(absDir, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + // Skip test directories. + if d.IsDir() && (d.Name() == "tests" || d.Name() == "test") { + return filepath.SkipDir + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(d.Name(), ".cpp") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + src := string(data) + for start := 0; ; { + idx := strings.Index(src[start:], "ImGui::BeginTable(") + if idx < 0 { + break + } + count++ + start += idx + len("ImGui::BeginTable(") + } + return nil + }) + if err != nil { + continue + } + + usesViz := strings.Contains(usesFunctions, "data_table_cpp_viz") + + audit := CppTableMigrationAudit{ + AppID: id, + DirPath: dir, + BeginTableCount: count, + UsesDataTableViz: usesViz, + } + + switch { + case count == 0: + audit.Status = "clean" + audit.Message = "" + case usesViz: + audit.Status = "mixed" + audit.Message = fmt.Sprintf("%d ImGui::BeginTable inline (partial migration OK — data_table_cpp_viz already declared)", count) + default: + audit.Status = "candidate" + audit.Message = fmt.Sprintf("candidate to migrate: %d ImGui::BeginTable inline detected, consider data_table_cpp_viz (issue 0081)", count) + } + + results = append(results, audit) + } + return results, nil +} From fe39de8b22d77a72e4d403da1af052067335b569 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 15:06:06 +0200 Subject: [PATCH 02/18] scaffolder: auto-wire fn_table_viz in new C++ apps (issue 0081-M) - CMakeLists.txt template: adds target_link_libraries fn_table_viz with if(TARGET fn_table_viz) guard (compiles even without vendor/lua). - main.cpp template: adds commented include + data_table::render() panel block in render(). Also fixes include to use app_base.h + panel_menu.h (matching convention of chart_demo, shaders_lab). - app.md template: uses_functions lists all 12 fn_table_viz stack IDs commented out; uncomment when activating data_table::render(). - Bump version 0.1.0 -> 1.1.0. Add capability growth log entry. - Tag cpp-tables added to capability group. - Verified: test app test_scaffolder_default compiled clean, residues removed (dir + CMakeLists entry). Co-Authored-By: Claude Sonnet 4.6 --- bash/functions/pipelines/init_cpp_app.md | 36 +++++++++++++++++++----- bash/functions/pipelines/init_cpp_app.sh | 30 ++++++++++++++++++-- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/bash/functions/pipelines/init_cpp_app.md b/bash/functions/pipelines/init_cpp_app.md index 2d149c6a..67810c12 100644 --- a/bash/functions/pipelines/init_cpp_app.md +++ b/bash/functions/pipelines/init_cpp_app.md @@ -3,11 +3,11 @@ name: init_cpp_app kind: pipeline lang: bash domain: pipelines -version: "0.1.0" +version: "1.1.0" purity: impure signature: "init_cpp_app(name: string, [--project

] [--domain ] [--desc ] [--tags ]) -> void" description: "Scaffolder estandar de apps C++ del registry. Genera main.cpp + CMakeLists.txt + app.md siguiendo el patron canonico (cfg.about/log/panels, sin app_menubar manual, dockspace via framework), registra la app en cpp/CMakeLists.txt, inicializa repo Gitea dataforge/ y ejecuta fn index." -tags: [cpp, imgui, scaffold, pipeline, bash, launcher] +tags: [cpp, imgui, scaffold, pipeline, bash, launcher, cpp-tables] uses_functions: - ensure_repo_synced_bash_infra uses_types: [] @@ -47,9 +47,9 @@ fn run init_cpp_app finance_panel --project budget --desc "Panel de finanzas" -- ```

/ - main.cpp # Plantilla canonica: panels[] + cfg.about + cfg.log + run_app(cfg, render) - CMakeLists.txt # add_imgui_app( main.cpp) - app.md # Frontmatter completo (lang:cpp, framework:imgui, dir_path, repo_url) + main.cpp # Plantilla canonica: panels[] + cfg.about + cfg.log + run_app(cfg, render) + data_table comentado + CMakeLists.txt # add_imgui_app( main.cpp) + target_link_libraries fn_table_viz (con guard) + app.md # Frontmatter completo (lang:cpp, framework:imgui, dir_path, repo_url) + uses_functions comentados ``` Y ademas: @@ -66,9 +66,31 @@ La plantilla cumple `cpp/PATTERNS.md`: - NO llama `DockSpaceOverViewport` (auto_dockspace=true por defecto). - Declara `panels[]` con un panel "Main" toggleable. - Setea `cfg.about` (window About) y `cfg.log` (logger + ventana Logs). +- Include `viz/data_table.h` comentado + panel "Data" comentado en `render()` — descomentar para activar `data_table::render()`. + +## Activar data_table::render() + +1. En `main.cpp`: descomentar `#include "viz/data_table.h"` y el bloque del panel Data en `render()`. +2. En `app.md`: descomentar los 12 IDs del stack `fn_table_viz` en `uses_functions`. +3. El `CMakeLists.txt` ya linka `fn_table_viz` via guard `if(TARGET fn_table_viz)` — sin cambio manual. +4. Poblar `data_tables` con tus `data_table::TableInput` y el panel aparece en el DockSpace. + +## Cuando usarla + +Cuando necesites crear una app C++ nueva que siga el patron canonico del registry. Es el unico camino autorizado para crear apps en `cpp/apps/` o `projects/*/apps/` — nunca escribir `main.cpp` + `CMakeLists.txt` a mano. + +## Gotchas + +- Si `GITEA_URL`/`GITEA_TOKEN` no estan seteados, solo hace `git init` local (no crea repo remoto). +- `fn_table_viz` requiere que `vendor/lua` este presente en `cpp/`; el guard `if(TARGET fn_table_viz)` evita errores de link si no esta disponible. +- El bloque de `uses_functions` en `app.md` queda comentado intencionalmente — descomenta solo las funciones que la app realmente use para mantener el grafo de dependencias limpio. ## Despues de crear -1. Editar `app.md` y completar `uses_functions` cuando la app consuma funciones del registry. -2. Anadir las funciones del registry al `CMakeLists.txt` como paths absolutos: `${CMAKE_SOURCE_DIR}/functions//.cpp`. +1. Si usas `data_table::render()`: descomentar include + panel en `main.cpp`, descomentar IDs en `app.md`, ejecutar `fn index`. +2. Para otras funciones del registry: anadir paths absolutos en `CMakeLists.txt` y los IDs en `uses_functions` de `app.md`. 3. Build: `cd cpp && cmake --build build --target -j`. + +## Capability growth log + +v1.1.0 (2026-05-15) — Auto-wires fn_table_viz; new apps get target_link_libraries + commented data_table template. diff --git a/bash/functions/pipelines/init_cpp_app.sh b/bash/functions/pipelines/init_cpp_app.sh index 489c8260..6d4fa7db 100755 --- a/bash/functions/pipelines/init_cpp_app.sh +++ b/bash/functions/pipelines/init_cpp_app.sh @@ -69,9 +69,11 @@ init_cpp_app() { # ---------- main.cpp ---------- cat > "$abs_dir/main.cpp" < -#include "framework/app_base.h" +#include "app_base.h" +#include "core/panel_menu.h" #include "core/icons_tabler.h" #include "core/logger.h" +// #include "viz/data_table.h" // uncomment to enable data_table::render() panel // Toggles de paneles (visibles desde el menu View del menubar canonico) static bool g_show_main = true; @@ -90,6 +92,11 @@ static void render() { // DockSpaceOverViewport central (auto_dockspace=true por defecto). // Aqui solo se dibujan los paneles propios de la app. if (g_show_main) draw_main(); + + // === Data panel (uncomment to enable) === + // static data_table::State data_state; + // static std::vector data_tables; // populate from your source + // data_table::render("main_data", data_tables, data_state); } int main(int /*argc*/, char** /*argv*/) { @@ -115,6 +122,12 @@ add_imgui_app($name ) target_include_directories($name PRIVATE \${CMAKE_CURRENT_SOURCE_DIR}) +# fn_table_viz: provides data_table::render(), viz_render, TQL engine, Lua, LLM. +# Guard keeps the app compilable in builds where vendor/lua is absent. +if(TARGET fn_table_viz) + target_link_libraries($name PRIVATE fn_table_viz) +endif() + if(WIN32) set_target_properties($name PROPERTIES WIN32_EXECUTABLE TRUE) endif() @@ -135,7 +148,20 @@ lang: cpp domain: $domain description: "$desc" tags: $tags_yaml -uses_functions: [] +uses_functions: + # Uncomment when using data_table::render() — provided via fn_table_viz: + # - data_table_cpp_viz + # - viz_render_cpp_viz + # - 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 + # - lua_engine_cpp_core + # - join_tables_cpp_core + # - tql_to_sql_cpp_core + # - llm_anthropic_cpp_core uses_types: [] framework: "imgui" entry_point: "main.cpp" From 7ecbee1175c6ea47a3788575ad41f6eeb15d140b Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 16:36:34 +0200 Subject: [PATCH 03/18] feat(dag_engine): WS hub /api/ws/dagruns + migracion DAGs desde dagu - events.go: DagRunHub broadcastea snapshot+deltas live (500ms tick, 5s recent finished window) sobre dag_runs + dag_step_results. - api.go: handler GET /api/ws/dagruns upgrade WS, opt-in en RegisterAPI. - store.go: expone Conn() para read-only desde el hub. - main.go: construye DagRunHub al arrancar server. - dags_migrated/: 5 YAMLs migrados desde ~/dagu/dags tras desinstalar dagu (issue 0095 step 1). Smoke: snapshot inicial OK, trigger /api/dags/test_claude_access/run -> delta WS observa 3 step_results + run success en <1s. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/dag_engine/api.go | 7 +- apps/dag_engine/dags_migrated/example.yaml | 23 + .../example_lineage_tracking.yaml | 178 +++++++ apps/dag_engine/dags_migrated/fn_backup.yaml | 21 + .../revision_viernes_finanzas.yaml | 51 ++ .../dags_migrated/test_claude_access.yaml | 23 + apps/dag_engine/events.go | 438 ++++++++++++++++++ apps/dag_engine/go.mod | 15 +- apps/dag_engine/go.sum | 28 +- apps/dag_engine/main.go | 3 +- apps/dag_engine/store/store.go | 6 + 11 files changed, 775 insertions(+), 18 deletions(-) create mode 100644 apps/dag_engine/dags_migrated/example.yaml create mode 100644 apps/dag_engine/dags_migrated/example_lineage_tracking.yaml create mode 100644 apps/dag_engine/dags_migrated/fn_backup.yaml create mode 100644 apps/dag_engine/dags_migrated/revision_viernes_finanzas.yaml create mode 100644 apps/dag_engine/dags_migrated/test_claude_access.yaml create mode 100644 apps/dag_engine/events.go diff --git a/apps/dag_engine/api.go b/apps/dag_engine/api.go index e112f30e..5b8abab2 100644 --- a/apps/dag_engine/api.go +++ b/apps/dag_engine/api.go @@ -6,7 +6,7 @@ import ( ) // RegisterAPI sets up all HTTP routes on the given mux. -func RegisterAPI(mux *http.ServeMux, executor *Executor, scheduler *Scheduler, frontendFS fs.FS) { +func RegisterAPI(mux *http.ServeMux, executor *Executor, scheduler *Scheduler, hub *DagRunHub, frontendFS fs.FS) { // API routes. mux.HandleFunc("GET /api/dags", handleListDags(executor)) mux.HandleFunc("GET /api/dags/{name}", handleGetDag(executor)) @@ -19,6 +19,11 @@ func RegisterAPI(mux *http.ServeMux, executor *Executor, scheduler *Scheduler, f mux.HandleFunc("POST /api/scheduler/stop", handleSchedulerStop(scheduler)) mux.HandleFunc("GET /api/scheduler/status", handleSchedulerStatus(scheduler)) + // Live updates (WS hub). + if hub != nil { + mux.HandleFunc("GET /api/ws/dagruns", handleDagRunsWS(hub)) + } + // Frontend SPA fallback. if frontendFS != nil { mux.Handle("/", spaHandler(frontendFS)) diff --git a/apps/dag_engine/dags_migrated/example.yaml b/apps/dag_engine/dags_migrated/example.yaml new file mode 100644 index 00000000..5d1ea9f9 --- /dev/null +++ b/apps/dag_engine/dags_migrated/example.yaml @@ -0,0 +1,23 @@ +# Example Dagu DAG +# This is a simple example workflow + +name: example +description: Example workflow to demonstrate Dagu capabilities + +schedule: + # Run every day at 9:00 AM + - "0 9 * * *" + +steps: + - name: hello + command: echo "Hello from Dagu!" + + - name: list_files + command: ls -la /home/lucas/dagu/scripts + depends: + - hello + + - name: date + command: date + depends: + - hello diff --git a/apps/dag_engine/dags_migrated/example_lineage_tracking.yaml b/apps/dag_engine/dags_migrated/example_lineage_tracking.yaml new file mode 100644 index 00000000..6e1934aa --- /dev/null +++ b/apps/dag_engine/dags_migrated/example_lineage_tracking.yaml @@ -0,0 +1,178 @@ +name: example_lineage_tracking +description: | + Ejemplo completo de pipeline con lineage tracking usando marquez-cli. + + Este DAG demuestra: + - Generación de Run ID único + - Eventos START, RUNNING, COMPLETE + - Tracking de inputs/outputs en cada paso + - Manejo de errores con evento FAIL + +tags: + - example + - lineage + - marquez + +schedule: + - "0 */6 * * *" # Cada 6 horas + +env: + - MARQUEZ_URL: http://localhost:5000 + - MARQUEZ_NAMESPACE: automatic-process + - JOB_NAME: example_lineage_tracking + - RUN_ID: "" + +steps: + # PASO 0: Generar Run ID único para todo el pipeline + - name: init_run_id + description: Generate unique Run ID for this execution + command: | + RUN_ID=$(uuidgen) + echo "RUN_ID=$RUN_ID" >> $DAGU_ENV + echo "Generated Run ID: $RUN_ID" + + # PASO 1: START event + - name: start_run + description: Send START event to Marquez + command: | + marquez-cli run start \ + -job $JOB_NAME \ + -run-id $RUN_ID \ + -namespace $MARQUEZ_NAMESPACE \ + -inputs "api://jsonplaceholder.typicode.com/users" + + echo "✓ Run started with ID: $RUN_ID" + depends: + - init_run_id + + # PASO 2: Extract - Fetch data from API + - name: extract_data + description: Fetch data from external API + command: | + echo "Fetching data from API..." + curl -s https://jsonplaceholder.typicode.com/users > /tmp/lineage_users.json + + marquez-cli run running \ + -job $JOB_NAME \ + -run-id $RUN_ID \ + -namespace $MARQUEZ_NAMESPACE \ + -inputs "api://jsonplaceholder.typicode.com/users" \ + -outputs "file:///tmp/lineage_users.json" + + echo "✓ Data extracted: $(cat /tmp/lineage_users.json | jq '. | length') records" + depends: + - start_run + + # PASO 3: Transform - Clean and transform data + - name: transform_data + description: Transform and clean the data + command: | + echo "Transforming data..." + jq '[.[] | {email: .email, name: .name, company: .company.name}]' \ + /tmp/lineage_users.json > /tmp/lineage_users_clean.json + + marquez-cli run running \ + -job $JOB_NAME \ + -run-id $RUN_ID \ + -namespace $MARQUEZ_NAMESPACE \ + -inputs "file:///tmp/lineage_users.json" \ + -outputs "file:///tmp/lineage_users_clean.json" + + echo "✓ Data transformed: $(cat /tmp/lineage_users_clean.json | jq '. | length') records" + depends: + - extract_data + + # PASO 4: Load - Save to PostgreSQL + - name: load_data + description: Load data to PostgreSQL + command: | + echo "Loading data to PostgreSQL..." + + # Crear tabla si no existe + psql -h localhost -p 5434 -U postgres -d postgres -c " + CREATE TABLE IF NOT EXISTS lineage_example ( + email TEXT, + name TEXT, + company TEXT, + loaded_at TIMESTAMP DEFAULT NOW() + ); + " + + # Truncar tabla + psql -h localhost -p 5434 -U postgres -d postgres -c "TRUNCATE TABLE lineage_example;" + + # Cargar datos + jq -r '.[] | [.email, .name, .company] | @csv' /tmp/lineage_users_clean.json | \ + psql -h localhost -p 5434 -U postgres -d postgres -c " + COPY lineage_example (email, name, company) FROM STDIN WITH CSV; + " + + RECORD_COUNT=$(psql -h localhost -p 5434 -U postgres -d postgres -t -c "SELECT COUNT(*) FROM lineage_example;") + + marquez-cli run running \ + -job $JOB_NAME \ + -run-id $RUN_ID \ + -namespace $MARQUEZ_NAMESPACE \ + -inputs "file:///tmp/lineage_users_clean.json" \ + -outputs "postgres://localhost:5434/postgres/public/lineage_example" + + echo "✓ Data loaded: $(echo $RECORD_COUNT | xargs) records" + depends: + - transform_data + + # PASO 5: COMPLETE event + - name: complete_run + description: Mark run as completed in Marquez + command: | + marquez-cli run complete \ + -job $JOB_NAME \ + -run-id $RUN_ID \ + -namespace $MARQUEZ_NAMESPACE \ + -inputs "api://jsonplaceholder.typicode.com/users" \ + -outputs "postgres://localhost:5434/postgres/public/lineage_example" + + echo "✓ Run completed successfully: $RUN_ID" + echo "" + echo "Verify lineage at: http://localhost:3001" + echo "Or run: marquez-cli lineage -name 'postgres://localhost:5434/postgres/public/lineage_example'" + depends: + - load_data + + # PASO 6: Cleanup temporary files + - name: cleanup + description: Remove temporary files + command: | + rm -f /tmp/lineage_users.json /tmp/lineage_users_clean.json + echo "✓ Temporary files cleaned" + depends: + - complete_run + +# Handler para errores +handlers: + failure: + - name: mark_as_failed + command: | + echo "❌ Pipeline failed, marking run as FAILED in Marquez" + + if [ -n "$RUN_ID" ]; then + marquez-cli run fail \ + -job $JOB_NAME \ + -run-id $RUN_ID \ + -namespace $MARQUEZ_NAMESPACE + + echo "✓ Run marked as FAILED: $RUN_ID" + else + echo "⚠ No RUN_ID found, skipping FAIL event" + fi + + success: + - name: notify_success + command: | + echo "🎉 Pipeline completed successfully!" + echo "Run ID: $RUN_ID" + echo "View lineage: http://localhost:3001" + +# Configuración de logs +logCleanup: + enabled: true + retentionDays: 7 diff --git a/apps/dag_engine/dags_migrated/fn_backup.yaml b/apps/dag_engine/dags_migrated/fn_backup.yaml new file mode 100644 index 00000000..69ebac8d --- /dev/null +++ b/apps/dag_engine/dags_migrated/fn_backup.yaml @@ -0,0 +1,21 @@ +name: fn_backup +description: Backup diario de fn_registry (registry.db + operations.db + vaults) + +schedule: + - "0 3 * * *" + +env: + - FN_REGISTRY_ROOT: /home/lucas/fn_registry + - BACKUP_ROOT: /home/lucas/backups/fn_registry + +steps: + - name: ensure_dirs + command: mkdir -p ${BACKUP_ROOT} + + - name: run_backup_all + command: bash /home/lucas/fn_registry/bash/functions/pipelines/backup_all.sh ${BACKUP_ROOT} + continue_on: + exit_code: [4] + + - name: report_status + command: bash -c 'ls -lh ${BACKUP_ROOT}/registry/daily.0 ${BACKUP_ROOT}/operations/*/daily.0 2>/dev/null | tail -20' diff --git a/apps/dag_engine/dags_migrated/revision_viernes_finanzas.yaml b/apps/dag_engine/dags_migrated/revision_viernes_finanzas.yaml new file mode 100644 index 00000000..6106e8d4 --- /dev/null +++ b/apps/dag_engine/dags_migrated/revision_viernes_finanzas.yaml @@ -0,0 +1,51 @@ +name: revision-viernes-finanzas +description: Revisión semanal de finanzas personales - ingesta, informe y push a Gitea +tags: [finanzas, semanal] +type: graph + +schedule: "0 9 * * 5" + +env: + - PROJECT_DIR: /home/lucas/analysis/finanzas_personales + - PYTHON: /home/lucas/analysis/finanzas_personales/.venv/bin/python + +handler_on: + failure: + command: echo "[$(date)] FALLÓ revision-viernes-finanzas" >> /home/lucas/dagu/logs/failures.log + +steps: + - id: ingest + description: Procesar archivos nuevos del inbox (BBVA xlsx + Revolut csv) + working_dir: ${PROJECT_DIR} + command: ./bin/ingest -skip-notebooks + continue_on: + failure: true + + - id: informe + description: Generar informe semanal de cumplimiento del presupuesto + command: ${PYTHON} /home/lucas/dagu/scripts/informe_finanzas.py + depends: [ingest] + + - id: git_push + description: Commit y push del informe a Gitea + working_dir: ${PROJECT_DIR} + script: | + #!/bin/bash + set -euo pipefail + + if git diff --quiet data/04_output/informe_semanal.md 2>/dev/null && \ + ! git ls-files --others --exclude-standard | grep -q informe_semanal.md; then + echo "Sin cambios en el informe, skip push" + exit 0 + fi + + git add data/04_output/informe_semanal.md + git add data/03_processed/ 2>/dev/null || true + + git commit -m "Informe semanal $(date +%Y-%m-%d) + + Co-Authored-By: Dagu Automation " + + git push origin master:main + echo "Push completado" + depends: [informe] diff --git a/apps/dag_engine/dags_migrated/test_claude_access.yaml b/apps/dag_engine/dags_migrated/test_claude_access.yaml new file mode 100644 index 00000000..875d6e96 --- /dev/null +++ b/apps/dag_engine/dags_migrated/test_claude_access.yaml @@ -0,0 +1,23 @@ +name: test_claude_access +description: Test workflow created by Claude to verify access + +tags: + - test + - claude + +steps: + - name: verify_access + command: echo "✓ Claude tiene acceso completo para gestionar tus pipelines de Dagu!" + + - name: show_info + command: | + echo "Usuario: $(whoami)" + echo "Fecha: $(date)" + echo "Directorio: $(pwd)" + depends: + - verify_access + + - name: cleanup + command: echo "Pipeline de prueba completado exitosamente" + depends: + - show_info diff --git a/apps/dag_engine/events.go b/apps/dag_engine/events.go new file mode 100644 index 00000000..d19f1a17 --- /dev/null +++ b/apps/dag_engine/events.go @@ -0,0 +1,438 @@ +package main + +// WebSocket hub para live updates de dag_runs + dag_step_results. +// Patron: sqlite_api/events.go (CallMonitorHub) — issue 0095. +// +// Diseño: +// - Hub global con N subscribers WS. +// - Ticker arranca solo con >=1 subscriber. Cero overhead si nadie mira. +// - Cada tick (500ms): query rowid>watermark + activos (status running/pending) +// + recientes finished (ultimos 5s) -> broadcast upsert. +// - Snapshot inicial: lista de DAGs + ultimos 50 runs + step_results. +// - El cliente trata `runs` y `steps` como upserts por id. + +import ( + "context" + "database/sql" + "log" + "net/http" + "sync" + "time" + + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" + + "dag-engine/store" +) + +const ( + dagWSTickInterval = 500 * time.Millisecond + dagWSTickIntervalIdle = 2 * time.Second + dagWSIdleThreshold = 30 * time.Second + dagWSSnapshotRuns = 50 + dagWSBroadcastTimeout = 2 * time.Second + dagWSRecentFinishedS = 5 +) + +type wsRun struct { + ID string `json:"id"` + DagName string `json:"dag_name"` + DagPath string `json:"dag_path"` + Status string `json:"status"` + Trigger string `json:"trigger"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at,omitempty"` + Error string `json:"error,omitempty"` +} + +type wsStep struct { + ID string `json:"id"` + RunID string `json:"run_id"` + StepName string `json:"step_name"` + Status string `json:"status"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + StartedAt string `json:"started_at,omitempty"` + FinishedAt string `json:"finished_at,omitempty"` + DurationMs int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` +} + +type wsWatermark struct { + Runs int64 `json:"runs"` + Steps int64 `json:"steps"` +} + +type wsDagMessage struct { + Type string `json:"type"` // snapshot|delta|ping + Watermark wsWatermark `json:"watermark"` + Dags []DagInfo `json:"dags,omitempty"` + Runs []wsRun `json:"runs,omitempty"` + Steps []wsStep `json:"steps,omitempty"` + ServerTime int64 `json:"server_time"` +} + +type wsDagClientCmd struct { + Watermark wsWatermark `json:"watermark,omitempty"` +} + +type dagSubscriber struct { + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + out chan wsDagMessage + watermark wsWatermark +} + +// DagRunHub broadcastea cambios de dag_runs + dag_step_results a clientes WS. +type DagRunHub struct { + db *store.DB + executor *Executor + + mu sync.Mutex + subscribers map[*dagSubscriber]struct{} + tickerStop chan struct{} + tickerOn bool + watermark wsWatermark + lastEventAt time.Time +} + +func NewDagRunHub(db *store.DB, executor *Executor) *DagRunHub { + return &DagRunHub{ + db: db, + executor: executor, + subscribers: make(map[*dagSubscriber]struct{}), + } +} + +func (h *DagRunHub) register(s *dagSubscriber) { + h.mu.Lock() + h.subscribers[s] = struct{}{} + shouldStart := !h.tickerOn + if shouldStart { + h.tickerStop = make(chan struct{}) + h.tickerOn = true + h.lastEventAt = time.Now() + } + h.mu.Unlock() + + if shouldStart { + go h.tickerLoop() + } +} + +func (h *DagRunHub) unregister(s *dagSubscriber) { + h.mu.Lock() + if _, ok := h.subscribers[s]; !ok { + h.mu.Unlock() + return + } + delete(h.subscribers, s) + close(s.out) + shouldStop := h.tickerOn && len(h.subscribers) == 0 + if shouldStop { + close(h.tickerStop) + h.tickerOn = false + } + h.mu.Unlock() +} + +func (h *DagRunHub) tickerLoop() { + interval := dagWSTickInterval + t := time.NewTimer(interval) + defer t.Stop() + for { + select { + case <-h.tickerStop: + return + case <-t.C: + runs, steps, wm, err := h.fetchDelta(h.getWatermark()) + if err != nil { + log.Printf("[dagws] fetchDelta: %v", err) + } else if len(runs) > 0 || len(steps) > 0 { + h.setWatermark(wm) + h.recordActivity() + h.broadcast(wsDagMessage{ + Type: "delta", + Watermark: wm, + Runs: runs, + Steps: steps, + ServerTime: time.Now().Unix(), + }) + } + + if time.Since(h.lastActivityAt()) > dagWSIdleThreshold { + interval = dagWSTickIntervalIdle + } else { + interval = dagWSTickInterval + } + t.Reset(interval) + } + } +} + +func (h *DagRunHub) getWatermark() wsWatermark { + h.mu.Lock() + defer h.mu.Unlock() + return h.watermark +} + +func (h *DagRunHub) setWatermark(v wsWatermark) { + h.mu.Lock() + if v.Runs > h.watermark.Runs { + h.watermark.Runs = v.Runs + } + if v.Steps > h.watermark.Steps { + h.watermark.Steps = v.Steps + } + h.mu.Unlock() +} + +func (h *DagRunHub) recordActivity() { + h.mu.Lock() + h.lastEventAt = time.Now() + h.mu.Unlock() +} + +func (h *DagRunHub) lastActivityAt() time.Time { + h.mu.Lock() + defer h.mu.Unlock() + return h.lastEventAt +} + +// fetchDelta devuelve runs/steps con (rowid > watermark) OR (status in-flight) +// OR (recently finished). Watermark devuelto = max rowid visto. +func (h *DagRunHub) fetchDelta(since wsWatermark) ([]wsRun, []wsStep, wsWatermark, error) { + conn := h.db.Conn() + if conn == nil { + return nil, nil, since, nil + } + cutoff := time.Now().Add(-time.Duration(dagWSRecentFinishedS) * time.Second).Format(time.RFC3339) + + runs, maxRuns, err := scanRuns(conn, ` + SELECT rowid, id, dag_name, dag_path, status, trigger, started_at, + COALESCE(finished_at,''), error + FROM dag_runs + WHERE rowid > ? + OR status IN ('running','pending') + OR (finished_at IS NOT NULL AND finished_at >= ?) + ORDER BY rowid ASC`, since.Runs, cutoff) + if err != nil { + return nil, nil, since, err + } + + steps, maxSteps, err := scanSteps(conn, ` + SELECT rowid, id, run_id, step_name, status, exit_code, stdout, stderr, + COALESCE(started_at,''), COALESCE(finished_at,''), duration_ms, error + FROM dag_step_results + WHERE rowid > ? + OR status IN ('running','pending') + OR (finished_at IS NOT NULL AND finished_at >= ?) + ORDER BY rowid ASC`, since.Steps, cutoff) + if err != nil { + return runs, nil, since, err + } + + out := wsWatermark{Runs: maxRuns, Steps: maxSteps} + if out.Runs < since.Runs { + out.Runs = since.Runs + } + if out.Steps < since.Steps { + out.Steps = since.Steps + } + return runs, steps, out, nil +} + +// fetchSnapshot devuelve DAGs + ultimos N runs + sus step_results + watermark. +func (h *DagRunHub) fetchSnapshot() ([]DagInfo, []wsRun, []wsStep, wsWatermark, error) { + dags, err := h.executor.ListDAGs() + if err != nil { + log.Printf("[dagws] list dags: %v", err) + dags = nil + } + conn := h.db.Conn() + if conn == nil { + return dags, nil, nil, wsWatermark{}, nil + } + + runs, maxRuns, err := scanRuns(conn, ` + SELECT rowid, id, dag_name, dag_path, status, trigger, started_at, + COALESCE(finished_at,''), error + FROM dag_runs + ORDER BY started_at DESC + LIMIT ?`, dagWSSnapshotRuns) + if err != nil { + return dags, nil, nil, wsWatermark{}, err + } + + steps, maxSteps, err := scanSteps(conn, ` + SELECT rowid, id, run_id, step_name, status, exit_code, stdout, stderr, + COALESCE(started_at,''), COALESCE(finished_at,''), duration_ms, error + FROM dag_step_results + WHERE run_id IN (SELECT id FROM dag_runs ORDER BY started_at DESC LIMIT ?) + ORDER BY rowid ASC`, dagWSSnapshotRuns) + if err != nil { + return dags, runs, nil, wsWatermark{Runs: maxRuns}, err + } + + return dags, runs, steps, wsWatermark{Runs: maxRuns, Steps: maxSteps}, nil +} + +func scanRuns(conn *sql.DB, q string, args ...any) ([]wsRun, int64, error) { + rows, err := conn.Query(q, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var out []wsRun + var max int64 + for rows.Next() { + var r wsRun + var rowid int64 + if err := rows.Scan(&rowid, &r.ID, &r.DagName, &r.DagPath, &r.Status, + &r.Trigger, &r.StartedAt, &r.FinishedAt, &r.Error); err != nil { + return nil, 0, err + } + if rowid > max { + max = rowid + } + out = append(out, r) + } + return out, max, rows.Err() +} + +func scanSteps(conn *sql.DB, q string, args ...any) ([]wsStep, int64, error) { + rows, err := conn.Query(q, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + var out []wsStep + var max int64 + for rows.Next() { + var s wsStep + var rowid int64 + if err := rows.Scan(&rowid, &s.ID, &s.RunID, &s.StepName, &s.Status, + &s.ExitCode, &s.Stdout, &s.Stderr, &s.StartedAt, &s.FinishedAt, + &s.DurationMs, &s.Error); err != nil { + return nil, 0, err + } + if rowid > max { + max = rowid + } + out = append(out, s) + } + return out, max, rows.Err() +} + +func (h *DagRunHub) broadcast(msg wsDagMessage) { + h.mu.Lock() + subs := make([]*dagSubscriber, 0, len(h.subscribers)) + for s := range h.subscribers { + subs = append(subs, s) + } + h.mu.Unlock() + + for _, s := range subs { + select { + case s.out <- msg: + default: + log.Printf("[dagws] dropping frame for slow subscriber") + } + } +} + +// handleDagRunsWS upgrade WS y gestiona lifecycle. +// Endpoint: GET /api/ws/dagruns +func handleDagRunsWS(hub *DagRunHub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + log.Printf("[dagws] accept: %v", err) + return + } + defer conn.Close(websocket.StatusInternalError, "closing") + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + sub := &dagSubscriber{ + conn: conn, + ctx: ctx, + cancel: cancel, + out: make(chan wsDagMessage, 64), + } + hub.register(sub) + defer hub.unregister(sub) + + dags, runs, steps, wm, err := hub.fetchSnapshot() + if err != nil { + log.Printf("[dagws] snapshot: %v", err) + conn.Close(websocket.StatusInternalError, "snapshot failed") + return + } + hub.setWatermark(wm) + initial := wsDagMessage{ + Type: "snapshot", + Watermark: wm, + Dags: dags, + Runs: runs, + Steps: steps, + ServerTime: time.Now().Unix(), + } + if err := wsjson.Write(ctx, conn, initial); err != nil { + return + } + + readErr := make(chan error, 1) + go func() { + for { + var cmd wsDagClientCmd + if err := wsjson.Read(ctx, conn, &cmd); err != nil { + readErr <- err + return + } + if cmd.Watermark.Runs > 0 || cmd.Watermark.Steps > 0 { + runs, steps, wm, err := hub.fetchDelta(cmd.Watermark) + if err == nil && (len(runs) > 0 || len(steps) > 0) { + hub.setWatermark(wm) + select { + case sub.out <- wsDagMessage{ + Type: "delta", + Watermark: wm, + Runs: runs, + Steps: steps, + ServerTime: time.Now().Unix(), + }: + default: + } + } + } + } + }() + + for { + select { + case <-ctx.Done(): + return + case err := <-readErr: + if err != nil { + return + } + case msg, ok := <-sub.out: + if !ok { + return + } + wctx, wcancel := context.WithTimeout(ctx, dagWSBroadcastTimeout) + err := wsjson.Write(wctx, conn, msg) + wcancel() + if err != nil { + return + } + } + } + } +} diff --git a/apps/dag_engine/go.mod b/apps/dag_engine/go.mod index 5eeabd69..a5d64fe8 100644 --- a/apps/dag_engine/go.mod +++ b/apps/dag_engine/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( fn-registry v0.0.0-00010101000000-000000000000 github.com/mattn/go-sqlite3 v1.14.37 + nhooyr.io/websocket v1.8.17 ) require ( @@ -28,19 +29,21 @@ require ( github.com/marcboeker/go-duckdb v1.8.5 // indirect github.com/paulmach/orb v0.12.0 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.29.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/dag_engine/go.sum b/apps/dag_engine/go.sum index d8b66118..6cc78806 100644 --- a/apps/dag_engine/go.sum +++ b/apps/dag_engine/go.sum @@ -111,44 +111,50 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc= golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -166,3 +172,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/apps/dag_engine/main.go b/apps/dag_engine/main.go index a3498001..a544c198 100644 --- a/apps/dag_engine/main.go +++ b/apps/dag_engine/main.go @@ -286,6 +286,7 @@ func cmdServer(args []string) { executor := NewExecutor(db, cfg.DagsDir) scheduler := NewScheduler(executor, cfg.DagsDir) + dagRunHub := NewDagRunHub(db, executor) // Prepare frontend FS. var feFS iofs.FS @@ -303,7 +304,7 @@ func cmdServer(args []string) { } mux := http.NewServeMux() - RegisterAPI(mux, executor, scheduler, feFS) + RegisterAPI(mux, executor, scheduler, dagRunHub, feFS) handler := corsMiddleware(loggingMiddleware(mux)) diff --git a/apps/dag_engine/store/store.go b/apps/dag_engine/store/store.go index 9f049062..f2b605bb 100644 --- a/apps/dag_engine/store/store.go +++ b/apps/dag_engine/store/store.go @@ -36,6 +36,12 @@ func (db *DB) Close() error { return db.conn.Close() } +// Conn exposes the underlying *sql.DB for read-only queries from other +// packages (e.g. WS hub in events.go). Do not Close() the returned conn. +func (db *DB) Conn() *sql.DB { + return db.conn +} + // --- DagRun CRUD --- // DagRun mirrors infra.DagRun for the store layer. From 380a7a8f35ac2a5724674554421710f8320ed195 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 16:38:24 +0200 Subject: [PATCH 04/18] data_table: declarative cell renderers Phase 1 (Badge/Progress/Duration/Icon) Adds TableInput.column_specs sidecar field enabling apps to declare Badge, Progress, Duration and Icon renderers per column without writing ImGui inline. Back-compat: apps without column_specs compile and behave identically. - data_table_types.h: CellRenderer enum, BadgeRule, IconMapEntry, ColumnSpec types - data_table.cpp: hex_to_imcolor helper, icon_name_to_glyph static map (~30 Tabler icons), draw_cell_custom dispatcher, integration in Stage-0 and Stage-N cell loops and draw_extra_panel - Bump version 1.0.0 -> 1.1.0 with capability growth log - cpp/tests/test_column_specs.cpp: 5 smoke/linker tests (back-compat + 4 renderer types) - cpp/tests/CMakeLists.txt: register test_column_specs target linked against fn_table_viz - types/core/{cell_renderer,badge_rule,icon_map_entry,column_spec}.md: registry type mds - docs/capabilities/data_table_renderers.md: canonical doc with end-to-end examples - docs/capabilities/INDEX.md: added data-table-renderers group All tests green: test_column_specs 5/5, test_fn_table_viz_smoke 8/8, tql_emit 41/41, tql_apply 88/88, Wave-1 tests 8/8. Co-Authored-By: Claude Sonnet 4.6 --- cpp/functions/core/data_table_types.h | 55 + cpp/functions/viz/data_table.cpp | 4174 +++++++++++++++++++++ cpp/functions/viz/data_table.md | 147 + cpp/tests/CMakeLists.txt | 111 + cpp/tests/test_column_specs.cpp | 196 + docs/capabilities/INDEX.md | 17 +- docs/capabilities/data_table_renderers.md | 127 + types/core/badge_rule.md | 22 + types/core/cell_renderer.md | 24 + types/core/column_spec.md | 32 + types/core/icon_map_entry.md | 24 + 11 files changed, 4924 insertions(+), 5 deletions(-) create mode 100644 cpp/functions/viz/data_table.cpp create mode 100644 cpp/functions/viz/data_table.md create mode 100644 cpp/tests/test_column_specs.cpp create mode 100644 docs/capabilities/data_table_renderers.md create mode 100644 types/core/badge_rule.md create mode 100644 types/core/cell_renderer.md create mode 100644 types/core/column_spec.md create mode 100644 types/core/icon_map_entry.md 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. From e0cce972ea3a1bd6f1c2b42d8316fb721841e101 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 16:38:43 +0200 Subject: [PATCH 05/18] feat(cpp): registrar dag_engine_ui en cpp/CMakeLists.txt (issue 0095 step 2) Sub-repo Gitea: dataforge/dag_engine_ui (a crear cuando se ejecute /full-git-push). Gitlink al SHA inicial del scaffolding. Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/CMakeLists.txt | 46 ++++++++++++++++++++++++++++++++++++++++++ cpp/apps/dag_engine_ui | 1 + 2 files changed, 47 insertions(+) create mode 160000 cpp/apps/dag_engine_ui diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index a5e9182a..1f8fce62 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -274,6 +274,46 @@ endfunction() # Functions are compiled as part of apps that use them via add_imgui_app. # Each function is a .h/.cpp pair included by the app's CMakeLists.txt. +# --- fn_table_viz: static lib bundling all Wave 1+2 tables-stack functions --- +# Issue 0081-I. Apps consumidores: target_link_libraries( PRIVATE fn_table_viz). +# data_table.cpp references playground-local headers (llm_anthropic.h, tql_to_sql.h, +# tql.h, data_table_logic.h). These are NOT available in the registry build — they +# live in the playground. fn_table_viz excludes data_table.cpp intentionally until +# those playground dependencies are promoted to the registry (Wave 4 deuda). +# The remaining 9 .cpp files compile cleanly with only registry headers. +if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/vendor/lua/CMakeLists.txt) +add_library(fn_table_viz STATIC + ${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_emit.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/core/tql_helpers.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/core/tql_apply.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/core/tql_to_sql.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/core/lua_engine.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/core/join_tables.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/core/auto_detect_type.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/core/compute_column_stats.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/core/llm_anthropic.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/viz/viz_render.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/functions/viz/data_table.cpp +) +target_include_directories(fn_table_viz PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/functions +) +target_include_directories(fn_table_viz PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/framework +) +target_compile_definitions(fn_table_viz PUBLIC FN_LLM_ANTHROPIC=1) +target_link_libraries(fn_table_viz PUBLIC + imgui + implot + lua54 +) +# fn::local_path() used by data_table.cpp (Ask AI export path + TQL save/load). +# fn_framework provides the implementation; link it here. +target_link_libraries(fn_table_viz PRIVATE fn_framework) +endif() + # --- Demo app --- if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/apps/chart_demo/CMakeLists.txt) add_subdirectory(apps/chart_demo) @@ -379,3 +419,9 @@ if(BUILD_TESTING) enable_testing() add_subdirectory(tests) endif() + + +# --- dag_engine_ui --- +if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/apps/dag_engine_ui/CMakeLists.txt) + add_subdirectory(apps/dag_engine_ui) +endif() diff --git a/cpp/apps/dag_engine_ui b/cpp/apps/dag_engine_ui new file mode 160000 index 00000000..334943b7 --- /dev/null +++ b/cpp/apps/dag_engine_ui @@ -0,0 +1 @@ +Subproject commit 334943b7db8dc98969e8ea8d3baf7a0c63be59df From ce9fa3b451222130c61dc86be027df2480e37f5f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 16:45:18 +0200 Subject: [PATCH 06/18] feat(dag_engine): json tags lowercase + gitlink dag_engine_ui HTTP layer (issue 0095 step 3) - store/store.go: anade tags JSON lowercase a DagRun + DagStepResult para que REST y WS devuelvan misma forma. - cpp/apps/dag_engine_ui: gitlink al SHA con http_client + data_http. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/dag_engine/store/store.go | 38 +++++++++++++++++----------------- cpp/apps/dag_engine_ui | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/dag_engine/store/store.go b/apps/dag_engine/store/store.go index f2b605bb..df17c500 100644 --- a/apps/dag_engine/store/store.go +++ b/apps/dag_engine/store/store.go @@ -46,14 +46,14 @@ func (db *DB) Conn() *sql.DB { // DagRun mirrors infra.DagRun for the store layer. type DagRun struct { - ID string - DagName string - DagPath string - Status string - Trigger string - StartedAt time.Time - FinishedAt *time.Time - Error string + ID string `json:"id"` + DagName string `json:"dag_name"` + DagPath string `json:"dag_path"` + Status string `json:"status"` + Trigger string `json:"trigger"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Error string `json:"error,omitempty"` } // CreateRun inserts a new run record. @@ -129,17 +129,17 @@ func (db *DB) ListRuns(dagName string, limit, offset int) ([]DagRun, int, error) // DagStepResult mirrors infra.DagStepResult for the store layer. type DagStepResult struct { - ID string - RunID string - StepName string - Status string - ExitCode int - Stdout string - Stderr string - StartedAt *time.Time - FinishedAt *time.Time - DurationMs int64 - Error string + ID string `json:"id"` + RunID string `json:"run_id"` + StepName string `json:"step_name"` + Status string `json:"status"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + DurationMs int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` } // InsertStepResult inserts a new step result. diff --git a/cpp/apps/dag_engine_ui b/cpp/apps/dag_engine_ui index 334943b7..44026d0a 160000 --- a/cpp/apps/dag_engine_ui +++ b/cpp/apps/dag_engine_ui @@ -1 +1 @@ -Subproject commit 334943b7db8dc98969e8ea8d3baf7a0c63be59df +Subproject commit 44026d0a70fbb441b307748c25d6a6e60bcf9e95 From 9ff0b3900c3cfd1069ab6fb295df2eb72d7c80b9 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 16:47:23 +0200 Subject: [PATCH 07/18] feat(dag_engine_ui): gitlink WS client (issue 0095 step 4) cpp/apps/dag_engine_ui: SHA con ws_client + panel Live integrado. Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/apps/dag_engine_ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/apps/dag_engine_ui b/cpp/apps/dag_engine_ui index 44026d0a..d01c7157 160000 --- a/cpp/apps/dag_engine_ui +++ b/cpp/apps/dag_engine_ui @@ -1 +1 @@ -Subproject commit 44026d0a70fbb441b307748c25d6a6e60bcf9e95 +Subproject commit d01c7157a14946d02a8bb4446a455ce574548b5a From 4027aeaaf572853d9c2630e8fe1180c3c10a6025 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 16:57:59 +0200 Subject: [PATCH 08/18] feat(dag_engine_ui): gitlink tabs DAG List/Detail/Run Detail (issue 0095 step 5) cpp/apps/dag_engine_ui: SHA con data_table_cpp_viz integrado. Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/apps/dag_engine_ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/apps/dag_engine_ui b/cpp/apps/dag_engine_ui index d01c7157..7a38fe9a 160000 --- a/cpp/apps/dag_engine_ui +++ b/cpp/apps/dag_engine_ui @@ -1 +1 @@ -Subproject commit d01c7157a14946d02a8bb4446a455ce574548b5a +Subproject commit 7a38fe9a4192898c1d797362640282c644f6c64f From c438dc6916405f0ddd2ee7876f56238e24a81bd8 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 16:59:26 +0200 Subject: [PATCH 09/18] =?UTF-8?q?data=5Ftable:=20Phase=202=20=E2=80=94=20B?= =?UTF-8?q?utton=20+=20events=20+=20tooltip=20+=20RightClick=20+=20TQL=20p?= =?UTF-8?q?ersist=20column=5Fspecs=20(issue=200081-O)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CellRenderer::Button=5: renders SmallButton per cell; emits TableEvent::ButtonClick on click - TableEventKind enum (ButtonClick/RowDoubleClick/RowRightClick/CellEdit) + TableEvent struct - render() extended overload: adds events_out parameter (nullptr = back-compat, no events) - RowDoubleClick and RowRightClick detection in raw table loop (stage 0) - RowRightClick also detected in aggregated stage table (stage 1+) - Tooltip per cell: tooltip_on_hover + tooltip fields on ColumnSpec; "auto" = show cell value - State::aux_column_specs: TQL-persisted column specs sidecar per table - tql_emit: serializes aux_column_specs[0] as column_specs block (badge/progress/duration/icon/button/tooltip) - tql_apply: parses column_specs block back into state.aux_column_specs[0] - render() merges aux_column_specs into TableInput when caller passes empty column_specs - test_column_specs: 5->8 tests (Button struct, tooltip fields, both render() signatures link) - tql_emit_test: 3 new tests (column_specs badge/button/tooltip emit) — 52 passed - tql_apply_test: 3 new tests (column_specs badge/button/tooltip roundtrip) — 106 passed - Back-compat: existing apps (graph_explorer, registry_dashboard) unchanged - Version bump: data_table v1.1.0 -> v1.2.0 Co-Authored-By: Claude Sonnet 4.6 --- cpp/functions/core/data_table_types.h | 43 +- cpp/functions/core/tql_apply.cpp | 672 ++++++++++++++++++++++ cpp/functions/core/tql_apply_test.cpp | 546 ++++++++++++++++++ cpp/functions/core/tql_emit.cpp | 370 ++++++++++++ cpp/functions/core/tql_emit_test.cpp | 329 +++++++++++ cpp/functions/viz/data_table.cpp | 149 ++++- cpp/functions/viz/data_table.h | 58 ++ cpp/functions/viz/data_table.md | 51 +- cpp/tests/test_column_specs.cpp | 137 ++++- cpp/tests/test_fn_table_viz_smoke.cpp | 191 ++++++ docs/capabilities/data_table_renderers.md | 87 ++- 11 files changed, 2593 insertions(+), 40 deletions(-) create mode 100644 cpp/functions/core/tql_apply.cpp create mode 100644 cpp/functions/core/tql_apply_test.cpp create mode 100644 cpp/functions/core/tql_emit.cpp create mode 100644 cpp/functions/core/tql_emit_test.cpp create mode 100644 cpp/functions/viz/data_table.h create mode 100644 cpp/tests/test_fn_table_viz_smoke.cpp diff --git a/cpp/functions/core/data_table_types.h b/cpp/functions/core/data_table_types.h index b5da3c64..f1ef0882 100644 --- a/cpp/functions/core/data_table_types.h +++ b/cpp/functions/core/data_table_types.h @@ -129,6 +129,7 @@ enum class JoinStrategy { Left, Inner, Right, Full }; // ---------------------------------------------------------------------------- // CellRenderer: declarative rendering mode per column (issue 0081-N, v1.1.0). +// Phase 2 (issue 0081-O, v1.2.0): Button=5 added. // ---------------------------------------------------------------------------- enum class CellRenderer : uint8_t { Text = 0, // default — current behavior @@ -136,7 +137,29 @@ enum class CellRenderer : uint8_t { 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. + Button = 5, // clickable button; emits TableEvent::ButtonClick + // Future (Phase 3): TextInput=6, Custom=7. IDs reserved. +}; + +// ---------------------------------------------------------------------------- +// TableEventKind: kinds of events emitted by render() via events_out. +// Issue 0081-O, v1.2.0. +// ---------------------------------------------------------------------------- +enum class TableEventKind : uint8_t { + ButtonClick = 1, // CellRenderer::Button was clicked + RowDoubleClick = 2, // row was double-clicked (left button) + RowRightClick = 3, // row right-clicked (open context menu) + CellEdit = 4, // reserved for Phase 3 (TextInput) +}; + +// TableEvent: carries the context of a single UI interaction. +struct TableEvent { + TableEventKind kind; + int row = -1; // index in TableInput (not StageOutput) + int col = -1; // column index in TableInput + std::string column_id; // ColumnSpec.id of the column + std::string action_id; // for ButtonClick: ColumnSpec.button_action + std::string value; // cell value at the click point }; // BadgeRule: maps a cell value to a colored badge label. @@ -155,7 +178,7 @@ struct IconMapEntry { // ColumnSpec: rendering spec for one column. Indexed by column position. struct ColumnSpec { - std::string id; // stable id, used in TQL (future) + std::string id; // stable id, used in TQL CellRenderer renderer = CellRenderer::Text; // Badge @@ -171,6 +194,15 @@ struct ColumnSpec { // Icon std::vector icon_map; + + // Button (Phase 2, v1.2.0): CellRenderer::Button + std::string button_action; // semantic id the app processes + std::string button_label; // button text; "" -> use cell value + std::string button_color_hex; // optional button color; "" -> default + + // Tooltip (Phase 2, v1.2.0): per-cell hover tooltip + std::string tooltip; // text; "auto" -> show cell value + bool tooltip_on_hover = false; // if true, show on hover }; // ---------------------------------------------------------------------------- @@ -241,6 +273,13 @@ struct State { std::vector col_visible; std::vector col_order; + // aux_column_specs (Phase 2, v1.2.0): TQL-persisted column specs sidecar. + // Parallel to tables[]; aux_column_specs[0] corresponds to tables[0], etc. + // Empty = no TQL-persisted specs. When non-empty, render() merges these + // into TableInput.column_specs if the caller passed an empty column_specs. + // Caller-provided column_specs take precedence over aux_column_specs. + std::vector> aux_column_specs; + // Helpers (definidos en compute_stage.cpp). Stage& raw(); const Stage& raw() const; diff --git a/cpp/functions/core/tql_apply.cpp b/cpp/functions/core/tql_apply.cpp new file mode 100644 index 00000000..ff7edb5c --- /dev/null +++ b/cpp/functions/core/tql_apply.cpp @@ -0,0 +1,672 @@ +// tql_apply.cpp — TQL parser using Lua 5.4 C API directly (no lua_engine). +// See tql_apply.h for documentation. +// Promoted from primitives_gallery/playground/tables/tql.cpp (apply()). + +#include "core/tql_apply.h" +#include "core/tql_helpers.h" + +extern "C" { +#include "lua.h" +#include "lualib.h" +#include "lauxlib.h" +} + +#include +#include +#include + +namespace tql { + +using namespace data_table; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- +namespace { + +// Find col index in a header list; returns -1 if not found. +int find_col(const std::vector& headers, const std::string& name) { + for (size_t i = 0; i < headers.size(); ++i) + if (headers[i] == name) return (int)i; + return -1; +} + +// Safe lua_tostring wrapper that never returns null. +std::string lua_to_str(lua_State* L, int idx) { + if (lua_isnil(L, idx)) return {}; + if (lua_isboolean(L, idx)) return lua_toboolean(L, idx) ? "true" : "false"; + size_t n = 0; + const char* s = luaL_tolstring(L, idx, &n); + std::string out(s, n); + lua_pop(L, 1); + return out; +} + +} // anon + +// --------------------------------------------------------------------------- +// apply +// --------------------------------------------------------------------------- +ApplyResult apply(const std::string& lua_text, + const std::vector& available_headers) +{ + ApplyResult res; + State& state = res.state; + + auto warn = [&](std::string msg) { + res.warnings.push_back(std::move(msg)); + }; + + // Create a fresh Lua state for parsing only (no engine libs needed). + lua_State* L = luaL_newstate(); + if (!L) { + res.error = "luaL_newstate failed"; + return res; + } + luaL_openlibs(L); // needed for string coercions (luaL_tolstring) + + // Load and execute the TQL chunk. + if (luaL_loadbufferx(L, lua_text.data(), lua_text.size(), "tql", "t") != LUA_OK) { + res.error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "load error"; + lua_close(L); + return res; + } + if (lua_pcall(L, 0, 1, 0) != LUA_OK) { + res.error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "exec error"; + lua_close(L); + return res; + } + if (!lua_istable(L, -1)) { + res.error = "TQL root must be a table"; + lua_close(L); + return res; + } + + // version check + lua_getfield(L, -1, "version"); + if (lua_isnil(L, -1)) { + warn("version missing (assuming 1)"); + } else if (!lua_isnumber(L, -1)) { + res.error = "version must be a number"; + lua_close(L); + return res; + } else { + int v = (int)lua_tointeger(L, -1); + if (v != 1) { + char buf[64]; + std::snprintf(buf, sizeof(buf), + "unsupported TQL version %d (expected 1)", v); + res.error = buf; + lua_close(L); + return res; + } + } + lua_pop(L, 1); + + // main_source + lua_getfield(L, -1, "main_source"); + if (lua_isstring(L, -1)) state.main_source = lua_tostring(L, -1); + else state.main_source.clear(); + lua_pop(L, 1); + + // display + lua_getfield(L, -1, "display"); + if (lua_isstring(L, -1)) { + std::string d = lua_tostring(L, -1); + ViewMode m = view_mode_from_token(d.c_str()); + state.display = m; + if (view_mode_token(m) != d) { + warn("unknown display \"" + d + "\" (defaulting to table)"); + } + } + lua_pop(L, 1); + + // Reset mutable state. + state.stages.clear(); + state.active_stage = 0; + state.color_rules.clear(); + state.joins.clear(); + + // ---- joins ---- + lua_getfield(L, -1, "joins"); + if (lua_istable(L, -1)) { + int nj = (int)lua_rawlen(L, -1); + for (int i = 1; i <= nj; ++i) { + lua_rawgeti(L, -1, i); + if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } + Join jn; + + lua_getfield(L, -1, "alias"); + if (lua_isstring(L, -1)) jn.alias = lua_tostring(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "source"); + if (lua_isstring(L, -1)) jn.source = lua_tostring(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "strategy"); + if (lua_isstring(L, -1)) + jn.strategy = join_strategy_from_token(lua_tostring(L, -1)); + lua_pop(L, 1); + + lua_getfield(L, -1, "on"); + if (lua_istable(L, -1)) { + int on_n = (int)lua_rawlen(L, -1); + for (int k = 1; k <= on_n; ++k) { + lua_rawgeti(L, -1, k); + if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) { + lua_rawgeti(L, -1, 1); std::string a = lua_to_str(L, -1); lua_pop(L, 1); + lua_rawgeti(L, -1, 2); std::string b = lua_to_str(L, -1); lua_pop(L, 1); + jn.on.push_back({a, b}); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + lua_getfield(L, -1, "fields"); + if (lua_istable(L, -1)) { + int fn_n = (int)lua_rawlen(L, -1); + for (int k = 1; k <= fn_n; ++k) { + lua_rawgeti(L, -1, k); + if (lua_isstring(L, -1)) jn.fields.emplace_back(lua_tostring(L, -1)); + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + state.joins.push_back(std::move(jn)); + lua_pop(L, 1); // pop join entry + } + } + lua_pop(L, 1); // joins + + // ---- stages ---- + lua_getfield(L, -1, "stages"); + if (lua_istable(L, -1)) { + int n_stages = (int)lua_rawlen(L, -1); + // cur_headers: tracks effective headers stage by stage for filter col lookup. + std::vector cur_headers = available_headers; + + for (int si = 1; si <= n_stages; ++si) { + lua_rawgeti(L, -1, si); + if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } + + Stage stg; + + // expressions (stage 0 only in practice; accepted in any stage for symmetry) + lua_getfield(L, -1, "expressions"); + if (lua_istable(L, -1)) { + lua_pushnil(L); + while (lua_next(L, -2) != 0) { + if (lua_isstring(L, -2) && lua_isstring(L, -1)) { + DerivedColumn d; + d.source_col = -1; + d.name = lua_tostring(L, -2); + d.formula = lua_tostring(L, -1); + d.lua_id = -1; // caller must compile + d.type = ColumnType::String; + stg.derived.push_back(std::move(d)); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); // expressions + + // filter + lua_getfield(L, -1, "filter"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 3) { + lua_rawgeti(L, -1, 1); std::string op = lua_to_str(L, -1); lua_pop(L, 1); + lua_rawgeti(L, -1, 2); std::string col = lua_to_str(L, -1); lua_pop(L, 1); + lua_rawgeti(L, -1, 3); std::string val = lua_to_str(L, -1); lua_pop(L, 1); + int ci = find_col(cur_headers, col); + if (ci >= 0) { + stg.filters.push_back({ci, op_from_label(op.c_str()), val}); + } else if (!cur_headers.empty()) { + // Only warn when headers were provided (validation mode). + warn("stage " + std::to_string(si - 1) + + ": filter col \"" + col + "\" not found"); + } else { + // No headers: store filter with col=-1 as placeholder. + stg.filters.push_back({-1, op_from_label(op.c_str()), val}); + } + // Validate op token. + if (op != "=" && op != "!=" && op != ">" && op != ">=" && + op != "<" && op != "<=" && op != "contains" && + op != "!contains" && op != "starts" && op != "ends") { + warn("stage " + std::to_string(si - 1) + + ": unknown filter op \"" + op + "\" (defaulting to =)"); + } + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); // filter + + // breakout + lua_getfield(L, -1, "breakout"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (lua_isstring(L, -1)) { + std::string bn = lua_tostring(L, -1); + if (!cur_headers.empty()) { + std::string clean; + parse_breakout_granularity(bn, clean); + if (find_col(cur_headers, clean) < 0) { + warn("stage " + std::to_string(si - 1) + + ": breakout col \"" + clean + + "\" not in input headers"); + } + } + stg.breakouts.emplace_back(std::move(bn)); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); // breakout + + // aggregation + lua_getfield(L, -1, "aggregation"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 1) { + Aggregation a; + lua_rawgeti(L, -1, 1); + std::string fn_name = lua_to_str(L, -1); + lua_pop(L, 1); + const bool known = (fn_name == "count" || fn_name == "sum" || + fn_name == "avg" || fn_name == "min" || + fn_name == "max" || fn_name == "distinct" || + fn_name == "stddev" || fn_name == "median" || + fn_name == "p25" || fn_name == "p75" || + fn_name == "p90" || fn_name == "p99" || + fn_name == "percentile"); + if (!known) { + warn("stage " + std::to_string(si - 1) + + ": unknown aggregation fn \"" + fn_name + + "\" (defaulting to count)"); + } + a.fn = agg_fn_from_string(fn_name); + if ((int)lua_rawlen(L, -1) >= 2) { + lua_rawgeti(L, -1, 2); + a.col = lua_to_str(L, -1); + lua_pop(L, 1); + if (a.fn != AggFn::Count && !cur_headers.empty() && + find_col(cur_headers, a.col) < 0) { + warn("stage " + std::to_string(si - 1) + + ": aggregation col \"" + a.col + + "\" not in input headers"); + } + } else if (a.fn != AggFn::Count) { + warn("stage " + std::to_string(si - 1) + + ": aggregation \"" + fn_name + "\" requires a column"); + } + if ((int)lua_rawlen(L, -1) >= 3) { + lua_rawgeti(L, -1, 3); + if (lua_isnumber(L, -1)) a.arg = lua_tonumber(L, -1); + lua_pop(L, 1); + } + stg.aggregations.push_back(std::move(a)); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); // aggregation + + // sort + lua_getfield(L, -1, "sort"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) { + lua_rawgeti(L, -1, 1); std::string dir = lua_to_str(L, -1); lua_pop(L, 1); + lua_rawgeti(L, -1, 2); std::string col = lua_to_str(L, -1); lua_pop(L, 1); + if (dir != "asc" && dir != "desc") { + warn("stage " + std::to_string(si - 1) + + ": unknown sort dir \"" + dir + "\" (defaulting to asc)"); + } + stg.sorts.push_back({col, dir == "desc"}); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); // sort + + state.stages.push_back(std::move(stg)); + + // Advance cur_headers for next stage col resolution. + const Stage& last = state.stages.back(); + if (si == 1) { + // Stage 0: cur_headers = orig + derived names. + for (const auto& d : last.derived) cur_headers.push_back(d.name); + } else { + if (!last.breakouts.empty() || !last.aggregations.empty()) { + std::vector next; + for (const auto& b : last.breakouts) next.push_back(b); + for (const auto& a : last.aggregations) next.push_back(aggregation_alias(a)); + cur_headers = std::move(next); + } + } + + lua_pop(L, 1); // pop stage entry + } + } + lua_pop(L, 1); // stages + + // ensure_stage0 equivalent: at least one stage. + if (state.stages.empty()) state.stages.push_back(Stage{}); + if (state.active_stage < 0) state.active_stage = 0; + if (state.active_stage >= (int)state.stages.size()) + state.active_stage = (int)state.stages.size() - 1; + + // ---- columns (per-col render config) ---- + int orig_cols = (int)available_headers.size(); + int eff_cols = orig_cols + (int)state.stages[0].derived.size(); + + lua_getfield(L, -1, "columns"); + if (lua_istable(L, -1) && eff_cols > 0) { + state.col_visible.assign(eff_cols, true); + std::vector> order_pairs; + std::vector seen(eff_cols, false); + + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } + + lua_getfield(L, -1, "name"); + std::string nm = lua_to_str(L, -1); + lua_pop(L, 1); + + int col_idx = find_col(available_headers, nm); + if (col_idx < 0) { + // Check derived. + const auto& der = state.stages[0].derived; + for (int di = 0; di < (int)der.size(); ++di) { + if (der[di].name == nm) { col_idx = orig_cols + di; break; } + } + } + if (col_idx < 0 || col_idx >= eff_cols) { lua_pop(L, 1); continue; } + seen[col_idx] = true; + + lua_getfield(L, -1, "visible"); + if (lua_isboolean(L, -1)) + state.col_visible[col_idx] = (bool)lua_toboolean(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "order"); + int order_val = lua_isnumber(L, -1) + ? (int)lua_tointeger(L, -1) : (col_idx + 1); + lua_pop(L, 1); + order_pairs.emplace_back(order_val, col_idx); + + // type: only mutable for derived cols. + lua_getfield(L, -1, "type"); + if (lua_isstring(L, -1)) { + ColumnType t = column_type_from_string(lua_tostring(L, -1)); + if (col_idx >= orig_cols && col_idx - orig_cols < (int)state.stages[0].derived.size()) + state.stages[0].derived[col_idx - orig_cols].type = t; + } + lua_pop(L, 1); + + // color_rules + lua_getfield(L, -1, "color_rules"); + if (lua_istable(L, -1)) { + int rn = (int)lua_rawlen(L, -1); + for (int j = 1; j <= rn; ++j) { + lua_rawgeti(L, -1, j); + if (lua_istable(L, -1)) { + lua_getfield(L, -1, "equals"); + std::string eq = lua_to_str(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "color"); + std::string hx = lua_to_str(L, -1); + lua_pop(L, 1); + state.color_rules.push_back({col_idx, eq, hex_to_color(hx)}); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); // color_rules + + lua_pop(L, 1); // col entry + } + + std::sort(order_pairs.begin(), order_pairs.end()); + state.col_order.clear(); + for (auto& p : order_pairs) state.col_order.push_back(p.second); + for (int c = 0; c < eff_cols; ++c) + if (!seen[c]) state.col_order.push_back(c); + } + lua_pop(L, 1); // columns + + // ---- views (viz panels) ---- + state.extra_panels.clear(); + lua_getfield(L, -1, "views"); + if (lua_istable(L, -1)) { + int n = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n; ++i) { + lua_rawgeti(L, -1, i); + if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } + + VizPanel p; + lua_getfield(L, -1, "display"); + if (lua_isstring(L, -1)) + p.display = view_mode_from_token(lua_tostring(L, -1)); + lua_pop(L, 1); + + auto read_str = [&](const char* key, std::string& out_s) { + lua_getfield(L, -1, key); + if (lua_isstring(L, -1)) out_s = lua_tostring(L, -1); + lua_pop(L, 1); + }; + read_str("x_col", p.config.x_col); + read_str("cat_col", p.config.cat_col); + read_str("size_col", p.config.size_col); + + lua_getfield(L, -1, "y_cols"); + if (lua_istable(L, -1)) { + int yn = (int)lua_rawlen(L, -1); + for (int j = 1; j <= yn; ++j) { + lua_rawgeti(L, -1, j); + if (lua_isstring(L, -1)) p.config.y_cols.emplace_back(lua_tostring(L, -1)); + lua_pop(L, 1); + } + } + lua_pop(L, 1); + + lua_getfield(L, -1, "color"); + if (lua_isstring(L, -1)) p.config.primary_color = hex_to_color(lua_tostring(L, -1)); + lua_pop(L, 1); + + lua_getfield(L, -1, "hist_bins"); + if (lua_isnumber(L, -1)) p.config.hist_bins = (int)lua_tointeger(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "pie_radius"); + if (lua_isnumber(L, -1)) p.config.pie_radius = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "show_legend"); + if (lua_isboolean(L, -1)) p.config.show_legend = (bool)lua_toboolean(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "show_markers"); + if (lua_isboolean(L, -1)) p.config.show_markers = (bool)lua_toboolean(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "locked"); + if (lua_isboolean(L, -1)) p.config.locked = (bool)lua_toboolean(L, -1); + lua_pop(L, 1); + + // Panel 0 = main viz (state.display + state.viz_config). + if (i == 1) { + state.display = p.display; + state.viz_config = p.config; + } else { + state.extra_panels.push_back(std::move(p)); + } + lua_pop(L, 1); // panel entry + } + } + lua_pop(L, 1); // views + + // ---- column_specs (Phase 2, v1.2.0) ---- + // Populate state.aux_column_specs[0] from optional "column_specs" block. + state.aux_column_specs.clear(); + lua_getfield(L, -1, "column_specs"); + if (lua_istable(L, -1)) { + std::vector specs; + int n_cs = (int)lua_rawlen(L, -1); + for (int i = 1; i <= n_cs; ++i) { + lua_rawgeti(L, -1, i); + if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; } + + data_table::ColumnSpec cs; + + lua_getfield(L, -1, "id"); + if (lua_isstring(L, -1)) cs.id = lua_tostring(L, -1); + lua_pop(L, 1); + + lua_getfield(L, -1, "renderer"); + if (lua_isstring(L, -1)) { + std::string rn = lua_tostring(L, -1); + if (rn == "badge") cs.renderer = data_table::CellRenderer::Badge; + else if (rn == "progress") cs.renderer = data_table::CellRenderer::Progress; + else if (rn == "duration") cs.renderer = data_table::CellRenderer::Duration; + else if (rn == "icon") cs.renderer = data_table::CellRenderer::Icon; + else if (rn == "button") cs.renderer = data_table::CellRenderer::Button; + else cs.renderer = data_table::CellRenderer::Text; + } + lua_pop(L, 1); + + // Badge rules + lua_getfield(L, -1, "badges"); + if (lua_istable(L, -1)) { + int nb = (int)lua_rawlen(L, -1); + for (int j = 1; j <= nb; ++j) { + lua_rawgeti(L, -1, j); + if (lua_istable(L, -1)) { + data_table::BadgeRule br; + lua_getfield(L, -1, "value"); + if (lua_isstring(L, -1)) br.value = lua_tostring(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "color"); + if (lua_isstring(L, -1)) br.color_hex = lua_tostring(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "label"); + if (lua_isstring(L, -1)) br.label = lua_tostring(L, -1); + lua_pop(L, 1); + cs.badges.push_back(std::move(br)); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); // badges + + // Progress + lua_getfield(L, -1, "progress_scale_100"); + if (lua_isboolean(L, -1)) cs.progress_scale_100 = (bool)lua_toboolean(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "progress_color"); + if (lua_isstring(L, -1)) cs.progress_color_hex = lua_tostring(L, -1); + lua_pop(L, 1); + + // Duration + lua_getfield(L, -1, "warn_ms"); + if (lua_isnumber(L, -1)) cs.duration_warn_ms = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "error_ms"); + if (lua_isnumber(L, -1)) cs.duration_error_ms = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + + // Icon map + lua_getfield(L, -1, "icon_map"); + if (lua_istable(L, -1)) { + int ni = (int)lua_rawlen(L, -1); + for (int j = 1; j <= ni; ++j) { + lua_rawgeti(L, -1, j); + if (lua_istable(L, -1)) { + data_table::IconMapEntry ie; + lua_getfield(L, -1, "value"); + if (lua_isstring(L, -1)) ie.value = lua_tostring(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "icon"); + if (lua_isstring(L, -1)) ie.icon_name = lua_tostring(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "color"); + if (lua_isstring(L, -1)) ie.color_hex = lua_tostring(L, -1); + lua_pop(L, 1); + cs.icon_map.push_back(std::move(ie)); + } + lua_pop(L, 1); + } + } + lua_pop(L, 1); // icon_map + + // Button + lua_getfield(L, -1, "button_action"); + if (lua_isstring(L, -1)) cs.button_action = lua_tostring(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "button_label"); + if (lua_isstring(L, -1)) cs.button_label = lua_tostring(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "button_color"); + if (lua_isstring(L, -1)) cs.button_color_hex = lua_tostring(L, -1); + lua_pop(L, 1); + + // Tooltip + lua_getfield(L, -1, "tooltip"); + if (lua_isstring(L, -1)) cs.tooltip = lua_tostring(L, -1); + lua_pop(L, 1); + lua_getfield(L, -1, "tooltip_on_hover"); + if (lua_isboolean(L, -1)) cs.tooltip_on_hover = (bool)lua_toboolean(L, -1); + lua_pop(L, 1); + + specs.push_back(std::move(cs)); + lua_pop(L, 1); // spec entry + } + if (!specs.empty()) { + state.aux_column_specs.push_back(std::move(specs)); + } + } + lua_pop(L, 1); // column_specs + + lua_pop(L, 1); // root + lua_close(L); + + res.ok = true; + return res; +} + +// --------------------------------------------------------------------------- +// apply (extended overload) — playground-compatible bool-returning wrapper. +// --------------------------------------------------------------------------- +bool apply(const std::string& lua_text, + data_table::State& st, + const std::vector& orig_headers, + const std::vector& /*orig_types*/, + const char* const* /*cells*/, + int /*rows*/, + int /*orig_cols*/, + std::string* err) +{ + ApplyResult res = apply(lua_text, orig_headers); + if (res.ok) { + st = std::move(res.state); + } else { + if (err) *err = res.error; + } + return res.ok; +} + +} // namespace tql diff --git a/cpp/functions/core/tql_apply_test.cpp b/cpp/functions/core/tql_apply_test.cpp new file mode 100644 index 00000000..9ce57201 --- /dev/null +++ b/cpp/functions/core/tql_apply_test.cpp @@ -0,0 +1,546 @@ +// tql_apply_test.cpp — Tests for tql_apply (TQL Lua parser). +// Run with: cmake --build cpp/build --target tql_apply_test && cpp/build/tql_apply_test +// Or via: ./fn run tql_apply_cpp_core + +#include "core/tql_apply.h" +#include "core/tql_emit.h" +#include "core/tql_helpers.h" + +#include +#include +#include +#include + +using namespace data_table; + +// --------------------------------------------------------------------------- +// Minimal assertions +// --------------------------------------------------------------------------- +static int g_pass = 0, g_fail = 0; + +static void check(bool cond, const char* label) { + if (cond) { + std::printf("PASS: %s\n", label); + ++g_pass; + } else { + std::printf("FAIL: %s\n", label); + ++g_fail; + } +} + +// --------------------------------------------------------------------------- +// Test: parse minimal TQL chunk +// --------------------------------------------------------------------------- +static void test_parse_minimal() { + const char* tql_text = R"( +return { + version = 1, + display = "table", + stages = { {} }, + columns = {}, + views = {}, + visualization_settings = {}, +} +)"; + auto res = tql::apply(tql_text, {}); + + check(res.ok, "parse minimal: ok=true"); + check(res.error.empty(), "parse minimal: no error"); + check(res.state.display == ViewMode::Table, "parse minimal: display=Table"); + check(!res.state.stages.empty(), "parse minimal: stages not empty"); +} + +// --------------------------------------------------------------------------- +// Test: parse display=bar +// --------------------------------------------------------------------------- +static void test_parse_display() { + const char* tql_text = R"( +return { + version = 1, + display = "bar", + stages = { {} }, + columns = {}, + views = {}, + visualization_settings = {}, +} +)"; + auto res = tql::apply(tql_text, {}); + check(res.ok, "parse display: ok"); + check(res.state.display == ViewMode::Bar, "parse display: display=Bar"); +} + +// --------------------------------------------------------------------------- +// Test: filter parsing with known header +// --------------------------------------------------------------------------- +static void test_parse_filter_known_col() { + const char* tql_text = R"( +return { + version = 1, + display = "table", + stages = { + { + filter = { + {"=", "name", "Alice"}, + }, + }, + }, + columns = {}, + views = {}, + visualization_settings = {}, +} +)"; + std::vector headers = {"name", "age"}; + auto res = tql::apply(tql_text, headers); + + check(res.ok, "filter known col: ok"); + check(res.warnings.empty(), "filter known col: no warnings"); + check(!res.state.stages.empty(), "filter known col: has stages"); + check(res.state.stages[0].filters.size() == 1, "filter known col: 1 filter"); + check(res.state.stages[0].filters[0].col == 0, "filter known col: col=0"); + check(res.state.stages[0].filters[0].op == Op::Eq,"filter known col: op=Eq"); + check(res.state.stages[0].filters[0].value == "Alice", "filter known col: value=Alice"); +} + +// --------------------------------------------------------------------------- +// Test: unknown column generates warning +// --------------------------------------------------------------------------- +static void test_parse_filter_unknown_col() { + const char* tql_text = R"( +return { + version = 1, + display = "table", + stages = { + { + filter = { + {"=", "nonexistent_col", "x"}, + }, + }, + }, + columns = {}, + views = {}, + visualization_settings = {}, +} +)"; + std::vector headers = {"name", "age"}; + auto res = tql::apply(tql_text, headers); + + check(res.ok, "unknown col: ok (warning not error)"); + check(!res.warnings.empty(), "unknown col: warning generated"); +} + +// --------------------------------------------------------------------------- +// Test: sort parsing +// --------------------------------------------------------------------------- +static void test_parse_sort() { + const char* tql_text = R"( +return { + version = 1, + display = "table", + stages = { + { + sort = { + {"desc", "age"}, + }, + }, + }, + columns = {}, + views = {}, + visualization_settings = {}, +} +)"; + auto res = tql::apply(tql_text, {}); + + check(res.ok, "sort: ok"); + check(res.state.stages[0].sorts.size() == 1, "sort: 1 sort clause"); + check(res.state.stages[0].sorts[0].col == "age", "sort: col=age"); + check(res.state.stages[0].sorts[0].desc == true, "sort: desc=true"); +} + +// --------------------------------------------------------------------------- +// Test: aggregation stage parsing +// --------------------------------------------------------------------------- +static void test_parse_aggregation() { + const char* tql_text = R"( +return { + version = 1, + display = "table", + stages = { + {}, + { + breakout = {"dept"}, + aggregation = { + {"sum", "salary"}, + {"count"}, + }, + sort = { + {"asc", "dept"}, + }, + }, + }, + columns = {}, + views = {}, + visualization_settings = {}, +} +)"; + auto res = tql::apply(tql_text, {}); + + check(res.ok, "aggregation: ok"); + check(res.state.stages.size() == 2, "aggregation: 2 stages"); + const Stage& s1 = res.state.stages[1]; + check(s1.breakouts.size() == 1, "aggregation: 1 breakout"); + check(s1.breakouts[0] == "dept", "aggregation: breakout=dept"); + check(s1.aggregations.size() == 2, "aggregation: 2 aggs"); + check(s1.aggregations[0].fn == AggFn::Sum, "aggregation: [0] fn=Sum"); + check(s1.aggregations[0].col == "salary", "aggregation: [0] col=salary"); + check(s1.aggregations[1].fn == AggFn::Count, "aggregation: [1] fn=Count"); + check(s1.sorts.size() == 1, "aggregation: 1 sort"); + check(s1.sorts[0].col == "dept", "aggregation: sort col=dept"); + check(s1.sorts[0].desc == false, "aggregation: sort asc"); +} + +// --------------------------------------------------------------------------- +// Test: expression (formula) stored verbatim, lua_id=-1 +// --------------------------------------------------------------------------- +static void test_parse_expressions() { + const char* tql_text = R"( +return { + version = 1, + display = "table", + stages = { + { + expressions = { + ["total"] = "return [price] * [qty]", + }, + }, + }, + columns = {}, + views = {}, + visualization_settings = {}, +} +)"; + auto res = tql::apply(tql_text, {}); + + check(res.ok, "expr: ok"); + check(res.state.stages[0].derived.size() == 1, "expr: 1 derived col"); + check(res.state.stages[0].derived[0].name == "total", "expr: name=total"); + check(res.state.stages[0].derived[0].formula == "return [price] * [qty]", + "expr: formula stored verbatim"); + check(res.state.stages[0].derived[0].lua_id == -1, "expr: lua_id=-1 (not compiled)"); +} + +// --------------------------------------------------------------------------- +// Test: views parsing (extra viz panel) +// --------------------------------------------------------------------------- +static void test_parse_views() { + const char* tql_text = R"( +return { + version = 1, + display = "table", + stages = { {} }, + columns = {}, + views = { + {display = "bar", x_col = "month", y_cols = {"revenue", "cost"}}, + {display = "line", x_col = "date"}, + }, + visualization_settings = {}, +} +)"; + auto res = tql::apply(tql_text, {}); + + check(res.ok, "views: ok"); + // Panel 0 sets state.display + state.viz_config. + check(res.state.display == ViewMode::Bar, "views: display=Bar (from panel 0)"); + check(res.state.viz_config.x_col == "month", "views: x_col=month"); + check(res.state.viz_config.y_cols.size() == 2, "views: y_cols has 2 entries"); + check(res.state.viz_config.y_cols[0] == "revenue", "views: y_cols[0]=revenue"); + // Panel 1 goes into extra_panels. + check(res.state.extra_panels.size() == 1, "views: 1 extra panel"); + check(res.state.extra_panels[0].display == ViewMode::Line, "views: extra=Line"); +} + +// --------------------------------------------------------------------------- +// Test: join parsing +// --------------------------------------------------------------------------- +static void test_parse_join() { + const char* tql_text = R"( +return { + version = 1, + display = "table", + joins = { + {alias = "d", source = "departments", strategy = "inner", + on = {{"dept_id", "id"}}, fields = {"dept_name"}}, + }, + stages = { {} }, + columns = {}, + views = {}, + visualization_settings = {}, +} +)"; + auto res = tql::apply(tql_text, {}); + + check(res.ok, "join: ok"); + check(res.state.joins.size() == 1, "join: 1 join"); + check(res.state.joins[0].alias == "d", "join: alias=d"); + check(res.state.joins[0].source == "departments", "join: source=departments"); + check(res.state.joins[0].strategy == JoinStrategy::Inner, "join: strategy=Inner"); + check(res.state.joins[0].on.size() == 1, "join: 1 on clause"); + check(res.state.joins[0].on[0].first == "dept_id", "join: on left=dept_id"); + check(res.state.joins[0].on[0].second == "id", "join: on right=id"); + check(res.state.joins[0].fields.size() == 1, "join: 1 field"); + check(res.state.joins[0].fields[0] == "dept_name", "join: field=dept_name"); +} + +// --------------------------------------------------------------------------- +// Test: version mismatch returns error +// --------------------------------------------------------------------------- +static void test_parse_version_mismatch() { + const char* tql_text = R"( +return { version = 99, display = "table", stages = {{}}, columns = {}, views = {} } +)"; + auto res = tql::apply(tql_text, {}); + + check(!res.ok, "version mismatch: ok=false"); + check(!res.error.empty(), "version mismatch: error set"); +} + +// --------------------------------------------------------------------------- +// Test: invalid Lua syntax returns error +// --------------------------------------------------------------------------- +static void test_parse_invalid_lua() { + const char* bad = "this is not lua %%%"; + auto res = tql::apply(bad, {}); + + check(!res.ok, "invalid lua: ok=false"); + check(!res.error.empty(), "invalid lua: error set"); +} + +// --------------------------------------------------------------------------- +// Test: roundtrip emit -> apply -> emit produces same display/stages +// --------------------------------------------------------------------------- +static void test_roundtrip() { + // Build a state with a filter and a grouped stage. + State st; + + Stage s0; + s0.filters.push_back({3, Op::Neq, "inactive"}); // status is index 3 + s0.sorts.push_back({"name", false}); + st.stages.push_back(s0); + + Stage s1; + s1.breakouts.push_back("dept"); + Aggregation a; + a.fn = AggFn::Avg; + a.col = "salary"; + s1.aggregations.push_back(a); + st.stages.push_back(s1); + + st.display = ViewMode::Bar; + st.viz_config.x_col = "dept"; + st.viz_config.y_cols = {"avg_salary"}; + + std::vector headers = {"name", "dept", "salary", "status"}; + std::vector types = { + ColumnType::String, ColumnType::String, ColumnType::Float, ColumnType::String + }; + + // Emit. + std::string tql_text = tql::emit(st, headers, types); + check(!tql_text.empty(), "roundtrip: emit produced text"); + + // Apply with the same headers. + auto res = tql::apply(tql_text, headers); + check(res.ok, "roundtrip: apply ok"); + check(res.warnings.empty(), "roundtrip: no warnings"); + + const State& st2 = res.state; + + check(st2.display == ViewMode::Bar, "roundtrip: display=Bar"); + check(st2.stages.size() == 2, "roundtrip: 2 stages"); + + // Stage 0 filter. + check(st2.stages[0].filters.size() == 1, "roundtrip: s0 1 filter"); + check(st2.stages[0].filters[0].col == 3, "roundtrip: s0 filter col=3 (status)"); + check(st2.stages[0].filters[0].op == Op::Neq, "roundtrip: s0 filter op=Neq"); + check(st2.stages[0].filters[0].value == "inactive", "roundtrip: s0 filter value=inactive"); + + // Stage 0 sort. + check(st2.stages[0].sorts.size() == 1, "roundtrip: s0 1 sort"); + check(st2.stages[0].sorts[0].col == "name", "roundtrip: s0 sort col=name"); + check(st2.stages[0].sorts[0].desc == false, "roundtrip: s0 sort asc"); + + // Stage 1 breakout. + check(st2.stages[1].breakouts.size() == 1, "roundtrip: s1 breakout"); + check(st2.stages[1].breakouts[0] == "dept", "roundtrip: s1 breakout=dept"); + + // Stage 1 aggregation. + check(st2.stages[1].aggregations.size() == 1, "roundtrip: s1 1 agg"); + check(st2.stages[1].aggregations[0].fn == AggFn::Avg, "roundtrip: s1 agg fn=Avg"); + check(st2.stages[1].aggregations[0].col == "salary", "roundtrip: s1 agg col=salary"); + + // viz_config. + check(st2.viz_config.x_col == "dept", "roundtrip: viz x_col=dept"); + check(st2.viz_config.y_cols.size() == 1, "roundtrip: viz 1 y_col"); + check(st2.viz_config.y_cols[0] == "avg_salary", "roundtrip: viz y_col=avg_salary"); +} + +// --------------------------------------------------------------------------- +// Test: column visibility and order preserved through roundtrip +// --------------------------------------------------------------------------- +static void test_roundtrip_col_order() { + State st; + st.stages.push_back(Stage{}); + st.col_visible = {true, false, true}; + st.col_order = {2, 0, 1}; // custom order + + std::vector headers = {"a", "b", "c"}; + std::vector types = {ColumnType::String, ColumnType::Int, ColumnType::Float}; + + std::string tql_text = tql::emit(st, headers, types); + auto res = tql::apply(tql_text, headers); + + check(res.ok, "col order roundtrip: ok"); + check(res.state.col_visible.size() == 3, "col order roundtrip: 3 visible entries"); + check(res.state.col_visible[1] == false, "col order roundtrip: col b invisible"); + // col_order after roundtrip should match the original (sorted by order value). + check(res.state.col_order.size() == 3, "col order roundtrip: 3 order entries"); + check(res.state.col_order[0] == 2, "col order roundtrip: first=c (idx 2)"); + check(res.state.col_order[1] == 0, "col order roundtrip: second=a (idx 0)"); + check(res.state.col_order[2] == 1, "col order roundtrip: third=b (idx 1)"); +} + +// --------------------------------------------------------------------------- +// Test: color rule roundtrip +// --------------------------------------------------------------------------- +static void test_roundtrip_color_rules() { + State st; + st.stages.push_back(Stage{}); + ColorRule cr{0, "Alice", 0xFF2244FFu}; + st.color_rules.push_back(cr); + + std::vector headers = {"name"}; + std::vector types = {ColumnType::String}; + + std::string tql_text = tql::emit(st, headers, types); + auto res = tql::apply(tql_text, headers); + + check(res.ok, "color rule roundtrip: ok"); + check(res.state.color_rules.size() == 1, "color rule roundtrip: 1 rule"); + check(res.state.color_rules[0].col == 0, "color rule roundtrip: col=0"); + check(res.state.color_rules[0].equals == "Alice", "color rule roundtrip: equals=Alice"); + check(res.state.color_rules[0].color == cr.color, "color rule roundtrip: color matches"); +} + +// --------------------------------------------------------------------------- +// Test: column_specs roundtrip — Badge +// --------------------------------------------------------------------------- +static void test_roundtrip_column_specs_badge() { + State st; + st.stages.push_back(Stage{}); + + ColumnSpec cs; + cs.id = "status"; + cs.renderer = CellRenderer::Badge; + cs.badges = { BadgeRule{"ok", "#22c55e", "OK"}, BadgeRule{"error", "#ef4444", ""} }; + st.aux_column_specs.push_back({cs}); + + std::vector headers = {"status"}; + std::vector types = {ColumnType::String}; + + std::string tql_text = tql::emit(st, headers, types); + auto res = tql::apply(tql_text, headers); + + check(res.ok, "cs badge roundtrip: ok"); + check(!res.state.aux_column_specs.empty(), "cs badge roundtrip: aux_column_specs non-empty"); + check(!res.state.aux_column_specs[0].empty(), "cs badge roundtrip: specs[0] non-empty"); + check(res.state.aux_column_specs[0][0].renderer == CellRenderer::Badge, + "cs badge roundtrip: renderer=Badge"); + check(res.state.aux_column_specs[0][0].id == "status", "cs badge roundtrip: id=status"); + check(res.state.aux_column_specs[0][0].badges.size() == 2, "cs badge roundtrip: 2 badge rules"); + check(res.state.aux_column_specs[0][0].badges[0].value == "ok", "cs badge roundtrip: badge[0].value=ok"); + check(res.state.aux_column_specs[0][0].badges[0].label == "OK", "cs badge roundtrip: badge[0].label=OK"); +} + +// --------------------------------------------------------------------------- +// Test: column_specs roundtrip — Button +// --------------------------------------------------------------------------- +static void test_roundtrip_column_specs_button() { + State st; + st.stages.push_back(Stage{}); + + ColumnSpec cs; + cs.id = "actions"; + cs.renderer = CellRenderer::Button; + cs.button_action = "cancel"; + cs.button_label = "Cancel"; + cs.button_color_hex = "#ef4444"; + st.aux_column_specs.push_back({cs}); + + std::vector headers = {"name", "actions"}; + std::vector types = {ColumnType::String, ColumnType::String}; + + std::string tql_text = tql::emit(st, headers, types); + auto res = tql::apply(tql_text, headers); + + check(res.ok, "cs button roundtrip: ok"); + check(!res.state.aux_column_specs.empty() && + !res.state.aux_column_specs[0].empty(), "cs button roundtrip: specs present"); + const auto& cs2 = res.state.aux_column_specs[0][0]; + check(cs2.renderer == CellRenderer::Button, "cs button roundtrip: renderer=Button"); + check(cs2.button_action == "cancel", "cs button roundtrip: button_action=cancel"); + check(cs2.button_label == "Cancel", "cs button roundtrip: button_label=Cancel"); + check(cs2.button_color_hex == "#ef4444", "cs button roundtrip: button_color"); +} + +// --------------------------------------------------------------------------- +// Test: column_specs roundtrip — Tooltip +// --------------------------------------------------------------------------- +static void test_roundtrip_column_specs_tooltip() { + State st; + st.stages.push_back(Stage{}); + + ColumnSpec cs; + cs.id = "name"; + cs.renderer = CellRenderer::Text; + cs.tooltip = "auto"; + cs.tooltip_on_hover = true; + st.aux_column_specs.push_back({cs}); + + std::vector headers = {"name"}; + std::vector types = {ColumnType::String}; + + std::string tql_text = tql::emit(st, headers, types); + auto res = tql::apply(tql_text, headers); + + check(res.ok, "cs tooltip roundtrip: ok"); + check(!res.state.aux_column_specs.empty() && + !res.state.aux_column_specs[0].empty(), "cs tooltip roundtrip: specs present"); + const auto& cs2 = res.state.aux_column_specs[0][0]; + check(cs2.tooltip == "auto", "cs tooltip roundtrip: tooltip=auto"); + check(cs2.tooltip_on_hover == true, "cs tooltip roundtrip: tooltip_on_hover=true"); +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +int main() { + test_parse_minimal(); + test_parse_display(); + test_parse_filter_known_col(); + test_parse_filter_unknown_col(); + test_parse_sort(); + test_parse_aggregation(); + test_parse_expressions(); + test_parse_views(); + test_parse_join(); + test_parse_version_mismatch(); + test_parse_invalid_lua(); + test_roundtrip(); + test_roundtrip_col_order(); + test_roundtrip_color_rules(); + test_roundtrip_column_specs_badge(); + test_roundtrip_column_specs_button(); + test_roundtrip_column_specs_tooltip(); + + std::printf("---\nResults: %d passed, %d failed\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/cpp/functions/core/tql_emit.cpp b/cpp/functions/core/tql_emit.cpp new file mode 100644 index 00000000..0ab7bd73 --- /dev/null +++ b/cpp/functions/core/tql_emit.cpp @@ -0,0 +1,370 @@ +// tql_emit.cpp — Pure TQL serialization. See tql_emit.h. +// Promoted from primitives_gallery/playground/tables/tql.cpp (emit()). + +#include "core/tql_emit.h" +#include "core/tql_helpers.h" + +#include +#include + +namespace tql { + +using namespace data_table; + +std::string emit(const State& state, + const std::vector& headers, + const std::vector& types) +{ + int orig_cols = (int)headers.size(); + const Stage& raw = state.stages.empty() ? Stage{} : state.stages[0]; + int eff_cols = orig_cols + (int)raw.derived.size(); + + // Build effective headers + types (same indexing as col_visible/order). + std::vector eff_headers(eff_cols); + std::vector eff_types(eff_cols); + for (int c = 0; c < orig_cols; ++c) { + eff_headers[c] = headers[c]; + eff_types[c] = (c < (int)types.size()) ? types[c] : ColumnType::Auto; + } + for (int k = 0; k < (int)raw.derived.size(); ++k) { + eff_headers[orig_cols + k] = raw.derived[k].name; + eff_types[orig_cols + k] = raw.derived[k].type; + } + + // Build order positions: col_idx -> visual order (1-based). + std::unordered_map order_pos; + for (size_t i = 0; i < state.col_order.size(); ++i) + order_pos[(int)state.col_order[i]] = (int)i + 1; + + // ---- Lambda helpers ---- + auto emit_filter_block = [&](const std::vector& filters, + const std::vector& stage_headers, + const char* indent) -> std::string { + if (filters.empty()) return {}; + std::string s; + s += indent; s += "filter = {\n"; + for (const auto& f : filters) { + std::string col_name = (f.col >= 0 && f.col < (int)stage_headers.size()) + ? stage_headers[f.col] : ""; + s += indent; s += " {"; + s += lua_string_literal(op_label(f.op)); + s += ", "; + s += lua_string_literal(col_name); + s += ", "; + s += lua_string_literal(f.value); + s += "},\n"; + } + s += indent; s += "},\n"; + return s; + }; + + auto emit_sort_block = [&](const std::vector& sorts, + const char* indent) -> std::string { + if (sorts.empty()) return {}; + std::string s; + s += indent; s += "sort = {\n"; + for (const auto& sc : sorts) { + s += indent; s += " {"; + s += lua_string_literal(sc.desc ? "desc" : "asc"); + s += ", "; + s += lua_string_literal(sc.col); + s += "},\n"; + } + s += indent; s += "},\n"; + return s; + }; + + auto emit_view = [&](const VizPanel& p) -> std::string { + std::string s = " {"; + s += "display = " + lua_string_literal(view_mode_token(p.display)); + if (!p.config.x_col.empty()) + s += ", x_col = " + lua_string_literal(p.config.x_col); + if (!p.config.cat_col.empty()) + s += ", cat_col = " + lua_string_literal(p.config.cat_col); + if (!p.config.size_col.empty()) + s += ", size_col = "+ lua_string_literal(p.config.size_col); + if (!p.config.y_cols.empty()) { + s += ", y_cols = {"; + for (size_t i = 0; i < p.config.y_cols.size(); ++i) { + if (i) s += ", "; + s += lua_string_literal(p.config.y_cols[i]); + } + s += "}"; + } + if (p.config.primary_color != 0) + s += ", color = " + lua_string_literal(color_to_hex(p.config.primary_color)); + if (p.config.hist_bins > 0) + s += ", hist_bins = " + std::to_string(p.config.hist_bins); + if (p.config.pie_radius > 0.0f) + s += ", pie_radius = " + std::to_string(p.config.pie_radius); + if (!p.config.show_legend) s += ", show_legend = false"; + if (p.config.show_markers) s += ", show_markers = true"; + if (p.config.locked) s += ", locked = true"; + s += "},\n"; + return s; + }; + + // ---- Build output ---- + std::string out; + out += "-- TQL v1 (Table Query Language). Round-trip de State <-> Lua.\n"; + out += "-- Schema:\n"; + out += "-- version = 1 -- bump si breaking change\n"; + out += "-- display = \"table\" -- table|bar|line|pie (futuro)\n"; + out += "-- stages = { stage0, stage1, ... } -- pipeline; stage 0 = Raw\n"; + out += "-- columns = { {name,type,visible,order,color_rules}, ... }\n"; + out += "--\n"; + out += "-- Stage 0 (Raw): filter + expressions + sort\n"; + out += "-- Stage N (Grouped): filter + breakout + aggregation + sort\n"; + out += "--\n"; + out += "-- filter: {{op, col, val}, ...} op in =,!=,>,>=,<,<=,contains,!contains,starts,ends\n"; + out += "-- expressions: {[name] = \"lua_body\"} ej: [\"total\"] = \"return [a] + [b]\"\n"; + out += "-- breakout: {\"col1\", \"col2\"} group by\n"; + out += "-- aggregation: {{fn, col, arg?}, ...} fn in count,sum,avg,min,max,distinct,stddev,median,p25,p75,p90,p99,percentile\n"; + out += "-- sort: {{dir, col}, ...} dir in asc,desc\n"; + out += "return {\n"; + out += " version = 1,\n"; + out += " display = "; + out += lua_string_literal(view_mode_token(state.display)); + out += ",\n"; + if (!state.main_source.empty()) { + out += " main_source = "; + out += lua_string_literal(state.main_source); + out += ",\n"; + } + + // joins + if (!state.joins.empty()) { + out += " joins = {\n"; + for (const auto& jn : state.joins) { + out += " {alias = " + lua_string_literal(jn.alias); + out += ", source = " + lua_string_literal(jn.source); + out += ", strategy = " + lua_string_literal(join_strategy_token(jn.strategy)); + out += ", on = {"; + for (size_t i = 0; i < jn.on.size(); ++i) { + if (i) out += ", "; + out += "{" + lua_string_literal(jn.on[i].first) + ", " + + lua_string_literal(jn.on[i].second) + "}"; + } + out += "}"; + if (!jn.fields.empty()) { + out += ", fields = {"; + for (size_t i = 0; i < jn.fields.size(); ++i) { + if (i) out += ", "; + out += lua_string_literal(jn.fields[i]); + } + out += "}"; + } + out += "},\n"; + } + out += " },\n"; + } + + out += " stages = {\n"; + + // Cur_headers tracks the output headers of each stage for filter/sort col name resolution. + std::vector cur_headers = headers; + + for (int si = 0; si < (int)state.stages.size(); ++si) { + const Stage& stg = state.stages[si]; + out += " {\n"; + + if (si == 0) { + // Stage 0 (Raw): filter + expressions + sort. + std::vector s0_headers = headers; + out += emit_filter_block(stg.filters, s0_headers, " "); + + if (!stg.derived.empty()) { + bool any_formula = false; + for (const auto& d : stg.derived) if (!d.formula.empty()) { any_formula = true; break; } + if (any_formula) { + out += " expressions = {\n"; + for (const auto& d : stg.derived) { + if (d.formula.empty()) continue; + out += " ["; + out += lua_string_literal(d.name); + out += "] = "; + out += lua_string_literal(d.formula); + out += ",\n"; + } + out += " },\n"; + } + } + + out += emit_sort_block(stg.sorts, " "); + + // Advance cur_headers: orig + derived. + for (const auto& d : stg.derived) cur_headers.push_back(d.name); + + } else { + // Stage 1+ (Grouped): filter + breakout + aggregation + sort. + out += emit_filter_block(stg.filters, cur_headers, " "); + + if (!stg.breakouts.empty()) { + out += " breakout = {"; + for (size_t i = 0; i < stg.breakouts.size(); ++i) { + if (i > 0) out += ", "; + out += lua_string_literal(stg.breakouts[i]); + } + out += "},\n"; + } + + if (!stg.aggregations.empty()) { + out += " aggregation = {\n"; + for (const auto& a : stg.aggregations) { + out += " {"; + out += lua_string_literal(agg_fn_token(a.fn)); + if (a.fn != AggFn::Count) { + out += ", "; + out += lua_string_literal(a.col); + } + if (a.fn == AggFn::Percentile) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%g", a.arg); + out += ", "; out += buf; + } + out += "},\n"; + } + out += " },\n"; + } + + out += emit_sort_block(stg.sorts, " "); + + // Advance cur_headers: breakouts + agg aliases. + std::vector next; + for (const auto& b : stg.breakouts) next.push_back(b); + for (const auto& a : stg.aggregations) next.push_back(aggregation_alias(a)); + cur_headers = std::move(next); + } + + out += " },\n"; + } + out += " },\n"; + + // columns — per-col render config (effective cols of stage 0). + out += " columns = {\n"; + for (int c = 0; c < eff_cols; ++c) { + out += " {"; + out += "name = " + lua_string_literal(eff_headers[c]); + out += ", type = " + lua_string_literal(column_type_name(eff_types[c])); + bool vis = (c < (int)state.col_visible.size()) ? state.col_visible[c] : true; + out += std::string(", visible = ") + (vis ? "true" : "false"); + int order = order_pos.count(c) ? order_pos.at(c) : c + 1; + out += ", order = " + std::to_string(order); + // color_rules for this col + bool first = true; + for (const auto& cr : state.color_rules) { + if (cr.col != c) continue; + if (first) { out += ", color_rules = {"; first = false; } + else { out += ", "; } + out += "{equals = " + lua_string_literal(cr.equals); + out += ", color = " + lua_string_literal(color_to_hex(cr.color)) + "}"; + } + if (!first) out += "}"; + out += "},\n"; + } + out += " },\n"; + + // views — main viz panel (index 0) + extra_panels. + out += " views = {\n"; + VizPanel main_p; + main_p.display = state.display; + main_p.config = state.viz_config; + out += emit_view(main_p); + for (const auto& p : state.extra_panels) out += emit_view(p); + out += " },\n"; + + out += " visualization_settings = {},\n"; + + // column_specs (Phase 2, v1.2.0): TQL-persisted declarative renderer specs. + // Only emitted when state.aux_column_specs is non-empty. + // Format: column_specs = { { id="col_id", renderer="badge|progress|...", ... }, ... } + if (!state.aux_column_specs.empty() && !state.aux_column_specs[0].empty()) { + const auto& specs = state.aux_column_specs[0]; + // Emit the block only if at least one spec has a non-default renderer OR tooltip. + bool any_renderable = false; + for (const auto& cs : specs) { + if (cs.renderer != data_table::CellRenderer::Text || cs.tooltip_on_hover) { + any_renderable = true; break; + } + } + if (any_renderable) { + out += " column_specs = {\n"; + for (const auto& cs : specs) { + if (cs.renderer == data_table::CellRenderer::Text && !cs.tooltip_on_hover) + continue; // skip pure-text cols without extras + out += " { id = " + lua_string_literal(cs.id); + // renderer + const char* rname = "text"; + switch (cs.renderer) { + case data_table::CellRenderer::Badge: rname = "badge"; break; + case data_table::CellRenderer::Progress: rname = "progress"; break; + case data_table::CellRenderer::Duration: rname = "duration"; break; + case data_table::CellRenderer::Icon: rname = "icon"; break; + case data_table::CellRenderer::Button: rname = "button"; break; + default: break; + } + out += ", renderer = " + lua_string_literal(rname); + // Badge rules + if (!cs.badges.empty()) { + out += ", badges = {\n"; + for (const auto& br : cs.badges) { + out += " { value = " + lua_string_literal(br.value); + out += ", color = " + lua_string_literal(br.color_hex); + if (!br.label.empty()) + out += ", label = " + lua_string_literal(br.label); + out += " },\n"; + } + out += " }"; + } + // Progress + if (cs.renderer == data_table::CellRenderer::Progress) { + if (cs.progress_scale_100) + out += ", progress_scale_100 = true"; + if (!cs.progress_color_hex.empty()) + out += ", progress_color = " + lua_string_literal(cs.progress_color_hex); + } + // Duration + if (cs.renderer == data_table::CellRenderer::Duration) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%g", (double)cs.duration_warn_ms); + out += std::string(", warn_ms = ") + buf; + std::snprintf(buf, sizeof(buf), "%g", (double)cs.duration_error_ms); + out += std::string(", error_ms = ") + buf; + } + // Icon map + if (!cs.icon_map.empty()) { + out += ", icon_map = {\n"; + for (const auto& ie : cs.icon_map) { + out += " { value = " + lua_string_literal(ie.value); + out += ", icon = " + lua_string_literal(ie.icon_name); + if (!ie.color_hex.empty()) + out += ", color = " + lua_string_literal(ie.color_hex); + out += " },\n"; + } + out += " }"; + } + // Button + if (cs.renderer == data_table::CellRenderer::Button) { + if (!cs.button_action.empty()) + out += ", button_action = " + lua_string_literal(cs.button_action); + if (!cs.button_label.empty()) + out += ", button_label = " + lua_string_literal(cs.button_label); + if (!cs.button_color_hex.empty()) + out += ", button_color = " + lua_string_literal(cs.button_color_hex); + } + // Tooltip + if (cs.tooltip_on_hover) { + out += ", tooltip = " + lua_string_literal(cs.tooltip.empty() ? "auto" : cs.tooltip); + out += ", tooltip_on_hover = true"; + } + out += " },\n"; + } + out += " },\n"; + } + } + + out += "}\n"; + return out; +} + +} // namespace tql diff --git a/cpp/functions/core/tql_emit_test.cpp b/cpp/functions/core/tql_emit_test.cpp new file mode 100644 index 00000000..d0421dba --- /dev/null +++ b/cpp/functions/core/tql_emit_test.cpp @@ -0,0 +1,329 @@ +// tql_emit_test.cpp — Tests for tql_emit (pure TQL serialization). +// Run with: cmake --build cpp/build --target tql_emit_test && cpp/build/tql_emit_test +// Or via: ./fn run tql_emit_cpp_core + +#include "core/tql_emit.h" +#include "core/tql_helpers.h" + +#include +#include +#include +#include +#include + +using namespace data_table; + +// --------------------------------------------------------------------------- +// Minimal assertions +// --------------------------------------------------------------------------- +static int g_pass = 0, g_fail = 0; + +static void check(bool cond, const char* label) { + if (cond) { + std::printf("PASS: %s\n", label); + ++g_pass; + } else { + std::printf("FAIL: %s\n", label); + ++g_fail; + } +} + +static bool contains(const std::string& haystack, const char* needle) { + return haystack.find(needle) != std::string::npos; +} + +// --------------------------------------------------------------------------- +// Test: emit empty state produces valid header +// --------------------------------------------------------------------------- +static void test_emit_empty_state() { + State st; + std::vector headers; + std::vector types; + std::string out = tql::emit(st, headers, types); + + check(contains(out, "version = 1"), "emit empty state: version=1"); + check(contains(out, "display = \"table\""), "emit empty state: display=table"); + check(contains(out, "stages = {"), "emit empty state: stages block"); + check(contains(out, "columns = {"), "emit empty state: columns block"); + check(contains(out, "views = {"), "emit empty state: views block"); +} + +// --------------------------------------------------------------------------- +// Test: emit state with one column +// --------------------------------------------------------------------------- +static void test_emit_single_column() { + State st; + st.stages.push_back(Stage{}); + st.col_visible = {true}; + st.col_order = {0}; + + std::vector headers = {"name"}; + std::vector types = {ColumnType::String}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "name = \"name\""), "emit single col: name field"); + check(contains(out, "type = \"string\""), "emit single col: type=string"); + check(contains(out, "visible = true"), "emit single col: visible=true"); + check(contains(out, "order = 1"), "emit single col: order=1"); +} + +// --------------------------------------------------------------------------- +// Test: emit state with filter in stage 0 +// --------------------------------------------------------------------------- +static void test_emit_filter() { + State st; + Stage s0; + s0.filters.push_back({0, Op::Eq, "Alice"}); + st.stages.push_back(s0); + + std::vector headers = {"name", "age"}; + std::vector types = {ColumnType::String, ColumnType::Int}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "filter = {"), "emit filter: filter block"); + check(contains(out, "\"=\""), "emit filter: op="); + check(contains(out, "\"name\""), "emit filter: col name"); + check(contains(out, "\"Alice\""), "emit filter: value"); +} + +// --------------------------------------------------------------------------- +// Test: emit state with sort +// --------------------------------------------------------------------------- +static void test_emit_sort() { + State st; + Stage s0; + s0.sorts.push_back({"age", true}); // desc + st.stages.push_back(s0); + + std::vector headers = {"name", "age"}; + std::vector types = {ColumnType::String, ColumnType::Int}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "sort = {"), "emit sort: sort block"); + check(contains(out, "\"desc\""), "emit sort: direction desc"); + check(contains(out, "\"age\""), "emit sort: col name age"); +} + +// --------------------------------------------------------------------------- +// Test: emit stage 1 with breakout + aggregation +// --------------------------------------------------------------------------- +static void test_emit_grouped_stage() { + State st; + st.stages.push_back(Stage{}); // stage 0 empty + + Stage s1; + s1.breakouts.push_back("dept"); + Aggregation a; + a.fn = AggFn::Sum; + a.col = "salary"; + s1.aggregations.push_back(a); + st.stages.push_back(s1); + + std::vector headers = {"dept", "salary"}; + std::vector types = {ColumnType::String, ColumnType::Float}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "breakout = {"), "emit grouped: breakout block"); + check(contains(out, "\"dept\""), "emit grouped: breakout col"); + check(contains(out, "aggregation = {"), "emit grouped: aggregation block"); + check(contains(out, "\"sum\""), "emit grouped: agg fn sum"); + check(contains(out, "\"salary\""), "emit grouped: agg col salary"); +} + +// --------------------------------------------------------------------------- +// Test: emit with color rule +// --------------------------------------------------------------------------- +static void test_emit_color_rule() { + State st; + st.stages.push_back(Stage{}); + ColorRule cr; + cr.col = 0; + cr.equals = "Alice"; + cr.color = 0xFF0000FF; // red, full alpha: ABGR -> r=0xFF g=0x00 b=0x00 a=0xFF + st.color_rules.push_back(cr); + + std::vector headers = {"name"}; + std::vector types = {ColumnType::String}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "color_rules = {"), "emit color rule: block"); + check(contains(out, "equals = \"Alice\""), "emit color rule: equals"); + check(contains(out, "color = \"#"), "emit color rule: hex color"); +} + +// --------------------------------------------------------------------------- +// Test: emit with viz panel (extra panel) +// --------------------------------------------------------------------------- +static void test_emit_viz_panel() { + State st; + st.stages.push_back(Stage{}); + st.display = ViewMode::Bar; + st.viz_config.x_col = "month"; + st.viz_config.y_cols = {"revenue", "cost"}; + + std::vector headers = {"month", "revenue", "cost"}; + std::vector types = {ColumnType::String, ColumnType::Float, ColumnType::Float}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "display = \"bar\""), "emit viz panel: display=bar"); + check(contains(out, "x_col = \"month\""), "emit viz panel: x_col"); + check(contains(out, "y_cols = {"), "emit viz panel: y_cols block"); + check(contains(out, "\"revenue\""), "emit viz panel: y_col revenue"); +} + +// --------------------------------------------------------------------------- +// Test: emit join +// --------------------------------------------------------------------------- +static void test_emit_join() { + State st; + st.stages.push_back(Stage{}); + Join jn; + jn.alias = "dept_table"; + jn.source = "departments"; + jn.strategy = JoinStrategy::Left; + jn.on.push_back({"dept_id", "id"}); + st.joins.push_back(jn); + + std::vector headers = {"dept_id", "name"}; + std::vector types = {ColumnType::Int, ColumnType::String}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "joins = {"), "emit join: joins block"); + check(contains(out, "alias = \"dept_table\""), "emit join: alias"); + check(contains(out, "source = \"departments\""), "emit join: source"); + check(contains(out, "strategy = \"left\""), "emit join: strategy left"); + check(contains(out, "\"dept_id\""), "emit join: on left col"); + check(contains(out, "\"id\""), "emit join: on right col"); +} + +// --------------------------------------------------------------------------- +// Test: lua_string_literal escaping +// --------------------------------------------------------------------------- +static void test_lua_string_literal() { + check(lua_string_literal("hello") == "\"hello\"", "literal: plain string"); + check(lua_string_literal("a\"b") == "\"a\\\"b\"", "literal: embedded quote"); + check(lua_string_literal("a\\b") == "\"a\\\\b\"", "literal: backslash"); + check(lua_string_literal("a\nb") == "\"a\\nb\"", "literal: newline"); + check(lua_string_literal("") == "\"\"", "literal: empty"); +} + +// --------------------------------------------------------------------------- +// Test: color_to_hex / hex_to_color roundtrip +// --------------------------------------------------------------------------- +static void test_color_roundtrip() { + // Opaque red in ABGR (0xAA BB GG RR): r=0xFF g=0x00 b=0x00 a=0xFF + unsigned int c = 0xFF0000FFu; + std::string hex = color_to_hex(c); + unsigned int back = hex_to_color(hex); + check(back == c, "color roundtrip: opaque red"); + + // Semi-transparent green + unsigned int c2 = (0x80u << 24) | (0xFFu << 8); // a=0x80 g=0xFF + std::string hex2 = color_to_hex(c2); + unsigned int back2 = hex_to_color(hex2); + check(back2 == c2, "color roundtrip: semi-transparent green"); +} + +// --------------------------------------------------------------------------- +// Test: emit with Badge column_spec in aux_column_specs +// --------------------------------------------------------------------------- +static void test_emit_column_specs_badge() { + State st; + st.stages.push_back(Stage{}); + + // Populate aux_column_specs[0] with a Badge spec. + ColumnSpec cs; + cs.id = "status"; + cs.renderer = CellRenderer::Badge; + cs.badges = { BadgeRule{"ok", "#22c55e", "OK"}, BadgeRule{"error", "#ef4444", ""} }; + st.aux_column_specs.push_back({cs}); + + std::vector headers = {"status", "value"}; + std::vector types = {ColumnType::String, ColumnType::Float}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "column_specs = {"), "emit column_specs badge: block"); + check(contains(out, "renderer = \"badge\""), "emit column_specs badge: renderer"); + check(contains(out, "id = \"status\""), "emit column_specs badge: id"); + check(contains(out, "\"#22c55e\""), "emit column_specs badge: color ok"); + check(contains(out, "\"error\""), "emit column_specs badge: value error"); +} + +// --------------------------------------------------------------------------- +// Test: emit with Button column_spec in aux_column_specs +// --------------------------------------------------------------------------- +static void test_emit_column_specs_button() { + State st; + st.stages.push_back(Stage{}); + + ColumnSpec cs; + cs.id = "actions"; + cs.renderer = CellRenderer::Button; + cs.button_action = "cancel"; + cs.button_label = "Cancel"; + cs.button_color_hex = "#ef4444"; + st.aux_column_specs.push_back({cs}); + + std::vector headers = {"name", "actions"}; + std::vector types = {ColumnType::String, ColumnType::String}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "renderer = \"button\""), "emit column_specs button: renderer"); + check(contains(out, "button_action = \"cancel\""), "emit column_specs button: action"); + check(contains(out, "button_label = \"Cancel\""), "emit column_specs button: label"); + check(contains(out, "button_color = \"#ef4444\""), "emit column_specs button: color"); +} + +// --------------------------------------------------------------------------- +// Test: emit with Tooltip column_spec in aux_column_specs +// --------------------------------------------------------------------------- +static void test_emit_column_specs_tooltip() { + State st; + st.stages.push_back(Stage{}); + + ColumnSpec cs; + cs.id = "name"; + cs.renderer = CellRenderer::Text; + cs.tooltip = "auto"; + cs.tooltip_on_hover = true; + st.aux_column_specs.push_back({cs}); + + std::vector headers = {"name"}; + std::vector types = {ColumnType::String}; + + std::string out = tql::emit(st, headers, types); + + check(contains(out, "tooltip = \"auto\""), "emit column_specs tooltip: auto"); + check(contains(out, "tooltip_on_hover = true"), "emit column_specs tooltip: on_hover"); +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +int main() { + test_emit_empty_state(); + test_emit_single_column(); + test_emit_filter(); + test_emit_sort(); + test_emit_grouped_stage(); + test_emit_color_rule(); + test_emit_viz_panel(); + test_emit_join(); + test_lua_string_literal(); + test_color_roundtrip(); + test_emit_column_specs_badge(); + test_emit_column_specs_button(); + test_emit_column_specs_tooltip(); + + std::printf("---\nResults: %d passed, %d failed\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/cpp/functions/viz/data_table.cpp b/cpp/functions/viz/data_table.cpp index c3a56904..6497f671 100644 --- a/cpp/functions/viz/data_table.cpp +++ b/cpp/functions/viz/data_table.cpp @@ -182,10 +182,14 @@ static const char* icon_name_to_glyph(const std::string& name) { // --------------------------------------------------------------------------- // draw_cell_custom: render a cell using the declarative ColumnSpec. // Called only when spec.renderer != CellRenderer::Text. -// Issue 0081-N, v1.1.0. +// Issue 0081-N, v1.1.0. Phase 2 (v1.2.0): Button renderer + tooltip. +// +// events_out: if non-null and renderer==Button, ButtonClick is pushed on click. +// row_idx / col_idx: logical indices in the TableInput (for event payload). // --------------------------------------------------------------------------- static void draw_cell_custom(const ColumnSpec& spec, const char* value, - int /*row_idx*/, int /*col_idx*/) { + int row_idx, int col_idx, + std::vector* events_out) { if (!value) value = ""; switch (spec.renderer) { @@ -284,11 +288,57 @@ static void draw_cell_custom(const ColumnSpec& spec, const char* value, break; } + case CellRenderer::Button: { + // Skip empty cell values — app decides when to show a button. + if (value[0] == '\0') break; + const char* label = spec.button_label.empty() ? value : spec.button_label.c_str(); + bool has_color = !spec.button_color_hex.empty(); + if (has_color) { + ImVec4 btn_col = hex_to_imcolor(spec.button_color_hex); + if (btn_col.x >= 0.f) { + ImGui::PushStyleColor(ImGuiCol_Button, btn_col); + ImVec4 hov = ImVec4( + std::min(btn_col.x + 0.12f, 1.f), + std::min(btn_col.y + 0.12f, 1.f), + std::min(btn_col.z + 0.12f, 1.f), + btn_col.w); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hov); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, hov); + } else { + has_color = false; + } + } + // Unique button ID: combines label + row + col to avoid ImGui ID + // collisions when the same label appears in multiple rows. + char btn_id[128]; + std::snprintf(btn_id, sizeof(btn_id), "%s##btn_%d_%d", + label, row_idx, col_idx); + if (ImGui::SmallButton(btn_id) && events_out) { + TableEvent ev; + ev.kind = TableEventKind::ButtonClick; + ev.row = row_idx; + ev.col = col_idx; + ev.column_id = spec.id; + ev.action_id = spec.button_action; + ev.value = value; + events_out->push_back(std::move(ev)); + } + if (has_color) ImGui::PopStyleColor(3); + break; + } + default: // CellRenderer::Text or unknown — plain text. ImGui::TextUnformatted(value); break; } + + // Tooltip: show on hover if tooltip_on_hover is set. + // "auto" shows the raw cell value (useful for truncated text columns). + if (spec.tooltip_on_hover && ImGui::IsItemHovered()) { + const char* tip = (spec.tooltip == "auto") ? value : spec.tooltip.c_str(); + if (tip && tip[0]) ImGui::SetTooltip("%s", tip); + } } // compare: cell-level comparison supporting all Op variants. @@ -1254,11 +1304,12 @@ bool draw_extra_panel(State& st, VizPanel& p, int idx, const StageOutput& so, ImGui::TableSetColumnIndex(c); const char* s = so.cells[(size_t)r * so.cols + c]; // Issue 0081-N: declarative renderer for extra panel mini-table. + // events_out not propagated to mini-table (secondary render). 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); + draw_cell_custom(cs, s, r, c, nullptr); custom_ep = true; } } @@ -2458,14 +2509,30 @@ void drill_into(State& st, int from_stage, void render(const char* id, const std::vector& tables, State& st, + std::vector* events_out, 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]; + // Merge aux_column_specs from State into TableInput when the caller passed + // empty column_specs. Caller-provided specs always take precedence. + // We keep a local copy to avoid mutating the caller's const tables. + static thread_local TableInput main_t_merged; + { + const TableInput& src = tables[(size_t)main_idx]; + if (src.column_specs.empty() && + main_idx < (int)st.aux_column_specs.size() && + !st.aux_column_specs[(size_t)main_idx].empty()) + { + main_t_merged = src; + main_t_merged.column_specs = st.aux_column_specs[(size_t)main_idx]; + } else { + main_t_merged = src; + } + } + const TableInput& main_t = main_t_merged; static thread_local std::vector main_hdr_ptrs; main_hdr_ptrs.clear(); main_hdr_ptrs.reserve(main_t.cols); @@ -3105,20 +3172,29 @@ void render(const char* id, 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. + // Issue 0081-N/O: use declarative renderer when column_specs set. { bool custom_rendered = false; + const ColumnSpec* cell_cs = nullptr; 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); + cell_cs = &main_t.column_specs[(size_t)c]; + if (cell_cs->renderer != CellRenderer::Text) { + draw_cell_custom(*cell_cs, cell, r, c, events_out); custom_rendered = true; } } if (!custom_rendered) { ImGui::Selectable(cell ? cell : "", in_sel, ImGuiSelectableFlags_AllowDoubleClick); + // Tooltip for Text cells (Phase 2). + if (cell_cs && cell_cs->tooltip_on_hover && + ImGui::IsItemHovered()) { + const char* tip = (cell_cs->tooltip == "auto") + ? (cell ? cell : "") + : cell_cs->tooltip.c_str(); + if (tip && tip[0]) ImGui::SetTooltip("%s", tip); + } } } // AllowWhenBlockedByActiveItem: durante drag, @@ -3135,10 +3211,36 @@ void render(const char* id, } else if (U.sel_dragging) { U.sel_end_row = ri; U.sel_end_col = oc; } + // RowDoubleClick event (Phase 2, v1.2.0). + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) + && events_out) { + TableEvent ev; + ev.kind = TableEventKind::RowDoubleClick; + ev.row = r; + ev.col = c; + ev.value = cell ? cell : ""; + if (!main_t.column_specs.empty() && + c < (int)main_t.column_specs.size()) + ev.column_id = main_t.column_specs[(size_t)c].id; + events_out->push_back(std::move(ev)); + } + // RowRightClick event: emit event only, no popup drawn here. + // Caller inspects events_out and opens its own context menu. if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { U.pending_col = c; U.pending_value = cell ? cell : ""; U.open_cell_popup = true; + if (events_out) { + TableEvent ev; + ev.kind = TableEventKind::RowRightClick; + ev.row = r; + ev.col = c; + ev.value = cell ? cell : ""; + if (!main_t.column_specs.empty() && + c < (int)main_t.column_specs.size()) + ev.column_id = main_t.column_specs[(size_t)c].id; + events_out->push_back(std::move(ev)); + } } } ImGui::PopID(); @@ -3658,19 +3760,28 @@ void render(const char* id, 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. + // Issue 0081-N/O: declarative renderer for aggregated stage tables. { bool custom_rendered = false; + const ColumnSpec* cell_cs2 = nullptr; 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); + cell_cs2 = &main_t.column_specs[(size_t)c]; + if (cell_cs2->renderer != CellRenderer::Text) { + draw_cell_custom(*cell_cs2, cell, r, c, events_out); custom_rendered = true; } } if (!custom_rendered) { ImGui::Selectable(cell ? cell : ""); + // Tooltip for Text cells (Phase 2). + if (cell_cs2 && cell_cs2->tooltip_on_hover && + ImGui::IsItemHovered()) { + const char* tip = (cell_cs2->tooltip == "auto") + ? (cell ? cell : "") + : cell_cs2->tooltip.c_str(); + if (tip && tip[0]) ImGui::SetTooltip("%s", tip); + } } } if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { @@ -3678,6 +3789,18 @@ void render(const char* id, U.pending_value = cell ? cell : ""; U.inspect_row = r; ImGui::OpenPopup("##drill_popup"); + // RowRightClick event (Phase 2, v1.2.0). + if (events_out) { + TableEvent ev; + ev.kind = TableEventKind::RowRightClick; + ev.row = r; + ev.col = c; + ev.value = cell ? cell : ""; + if (!main_t.column_specs.empty() && + c < (int)main_t.column_specs.size()) + ev.column_id = main_t.column_specs[(size_t)c].id; + events_out->push_back(std::move(ev)); + } } if (ImGui::BeginPopup("##drill_popup")) { if (c < n_brk) { diff --git a/cpp/functions/viz/data_table.h b/cpp/functions/viz/data_table.h new file mode 100644 index 00000000..a3ec0b55 --- /dev/null +++ b/cpp/functions/viz/data_table.h @@ -0,0 +1,58 @@ +#pragma once +// 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.h +// Phase 2 (issue 0081-O, v1.2.0): Button renderer + event sink + tooltip + RightClick. +// +// Uso basico (back-compat, sin events): +// data_table::State st; // persistir entre frames +// ImGui::Begin("Window"); ImGui::BeginChild("tbl", {-1,-1}); +// data_table::render("my_table", {table1, table2}, st); +// ImGui::EndChild(); ImGui::End(); +// +// Uso con events (Phase 2): +// std::vector events; +// data_table::render("my_table", {table1, table2}, st, &events); +// for (auto& ev : events) { +// if (ev.kind == data_table::TableEventKind::ButtonClick && +// ev.action_id == "cancel") { ... } +// } +// +// Requiere ImGui context + ImPlot context activos. +// Namespace identico al playground para facilitar migracion (solo cambiar include path). + +#include "core/data_table_types.h" +#include + +namespace data_table { + +// render — Render barra-de-chips + tabla + panels de visualizacion. +// Mutates `st` en respuesta a la interaccion del usuario. +// +// `id` — ID unico de ImGui para esta instancia (ej. "##my_table"). +// `tables` — lista de TableInput. 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. +// `st` — estado mutable. Debe persistir entre frames (no stack-local). +// `events_out` — if non-null, populated with UI events (ButtonClick, +// RowDoubleClick, RowRightClick) fired this frame. The caller +// clears/reads the vector after each render call. +// Pass nullptr to disable event collection (back-compat). +// `show_chrome` — si false, oculta la barra de chips + breadcrumb por defecto. +// El usuario puede reactivarla via el boton "Show UI". +void render(const char* id, + const std::vector& tables, + State& st, + std::vector* events_out, + bool show_chrome = true); + +// Overload for back-compat: same as render(..., nullptr, show_chrome). +inline void render(const char* id, + const std::vector& tables, + State& st, + bool show_chrome = true) +{ + render(id, tables, st, nullptr, show_chrome); +} + +} // namespace data_table diff --git a/cpp/functions/viz/data_table.md b/cpp/functions/viz/data_table.md index cd5c9c70..1c4f2613 100644 --- a/cpp/functions/viz/data_table.md +++ b/cpp/functions/viz/data_table.md @@ -3,10 +3,10 @@ name: data_table kind: function lang: cpp domain: viz -version: "1.1.0" +version: "1.2.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." +signature: "void data_table::render(const char* id, const std::vector& tables, State& st, std::vector* events_out = nullptr, 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, Button renderer, event sink (ButtonClick/RowDoubleClick/RowRightClick), tooltip per-cell, column_specs persisted in TQL. 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 @@ -40,6 +40,8 @@ uses_types: - Aggregation_cpp_core - SortClause_cpp_core - ColumnType_cpp_core + - TableEvent_cpp_core + - TableEventKind_cpp_core returns: [] returns_optional: false error_type: "error_go_core" @@ -65,6 +67,9 @@ tests: - "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" + - "Button: TableEvent struct constructible; render() with events_out links" + - "Tooltip: ColumnSpec with tooltip_on_hover=true compiles and links" + - "Back-compat: both render() signatures (with/without events_out) link" test_file_path: "cpp/tests/test_column_specs.cpp" file_path: "cpp/functions/viz/data_table.cpp" params: @@ -73,10 +78,12 @@ params: - 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." + desc: "Estado mutable completo: pipeline de stages, joins, viz config, ui tweaks, aux_column_specs (Phase 2). Debe persistir entre frames — no declarar en el stack del frame." + - name: events_out + desc: "Puntero a vector de TableEvent. Si non-null, se puebla con eventos de este frame (ButtonClick, RowDoubleClick, RowRightClick). El caller limpia/lee el vector despues de cada render. Pasar nullptr para desactivar (back-compat)." - 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." +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. Events emitted via events_out." --- ## Ejemplo @@ -91,19 +98,43 @@ 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.headers = {"id", "amount", "status", "actions"}; t.types = {data_table::ColumnType::Int, data_table::ColumnType::Float, + data_table::ColumnType::String, data_table::ColumnType::String}; +// Phase 2: declarative renderers + tooltip +t.column_specs.resize(4); +t.column_specs[2].renderer = data_table::CellRenderer::Badge; +t.column_specs[2].badges = {{"paid","#22c55e","Paid"},{"pending","#f59e0b",""}}; +t.column_specs[2].tooltip = "auto"; +t.column_specs[2].tooltip_on_hover = true; +t.column_specs[3].renderer = data_table::CellRenderer::Button; +t.column_specs[3].button_action = "cancel_order"; +t.column_specs[3].button_label = "Cancel"; + data_table::State st; // persiste entre frames +std::vector events; // --- Render (cada frame) --- ImGui::Begin("Orders"); ImGui::BeginChild("##tbl", ImVec2(-1, -1)); -data_table::render("##orders", {t}, st); +events.clear(); +data_table::render("##orders", {t}, st, &events); ImGui::EndChild(); ImGui::End(); + +// --- Process events --- +for (const auto& ev : events) { + if (ev.kind == data_table::TableEventKind::ButtonClick && + ev.action_id == "cancel_order") { + cancel_order(ev.row); // app handles the action + } + if (ev.kind == data_table::TableEventKind::RowDoubleClick) { + open_order_detail(ev.row); + } +} ``` ## Cuando usarla @@ -117,6 +148,10 @@ Cuando una app necesita tabla con filtros + agregaciones + viz + joins sobre dat - **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. +- **events_out no se limpia**: `render()` solo hace `push_back`. El caller debe llamar `events.clear()` antes de cada frame o acumulara eventos de frames anteriores. +- **Button + celda vacia**: si el cell value es vacio, el boton NO se dibuja. La app controla cuando mostrar el boton poniendo un value no vacio (ej. "1" o el ID de la fila). +- **RowRightClick emite evento Y abre popup interno**: la tabla de stages (stage>0) sigue abriendo su popup de drill. En el raw table (stage 0), se emite el evento pero el popup de drill antiguo tambien puede abrirse via `U.open_cell_popup`. El caller puede ignorar el popup interno y gestionar su propio menu al detectar `RowRightClick`. +- **aux_column_specs merge**: si `TableInput.column_specs` esta vacio pero `State.aux_column_specs[0]` no, `render()` los aplica automaticamente. Si el caller pasa column_specs no vacios, ganan sobre los del State. - **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. @@ -143,5 +178,7 @@ No hay tests unitarios directos: `render()` requiere ImGui + ImPlot context acti 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. +v1.2.0 (2026-05-15) — Button renderer + event sink (ButtonClick/RowDoubleClick/RowRightClick) + tooltip per cell + column_specs persisted in TQL (aux_column_specs roundtrip). Back-compat preserved: events_out=nullptr by default; existing render() callers unchanged. + --- Promovido desde `cpp/apps/primitives_gallery/playground/tables/data_table.{h,cpp}` — issue 0081-H. diff --git a/cpp/tests/test_column_specs.cpp b/cpp/tests/test_column_specs.cpp index 5cc5c04c..a10ac0f2 100644 --- a/cpp/tests/test_column_specs.cpp +++ b/cpp/tests/test_column_specs.cpp @@ -1,9 +1,12 @@ // test_column_specs.cpp — Smoke / back-compat tests for declarative cell renderers. -// Issue 0081-N, v1.1.0. +// Issue 0081-N, v1.1.0. Phase 2 (issue 0081-O, v1.2.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. +// 6. Button renderer: TableEvent struct is constructible; events_out pointer accepted. +// 7. Tooltip field: ColumnSpec with tooltip_on_hover=true compiles and links. +// 8. render() overload with events_out=nullptr back-compat (symbol resolution only). // // 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 @@ -50,7 +53,11 @@ static void test_no_column_specs() { // 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; + // Use the classic overload (without events_out) for the back-compat check. + auto* render_fn = static_cast&, + State&, + bool)>(&data_table::render); (void)render_fn; std::printf("PASS: test_no_column_specs (back-compat, column_specs empty)\n"); @@ -174,13 +181,130 @@ static void test_icon_column_spec() { 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; + // Verify render symbol still links with column_specs populated (classic overload). + auto* render_fn = static_cast&, + State&, + bool)>(&data_table::render); (void)render_fn; std::printf("PASS: test_icon_column_spec (2 entries, render symbol links)\n"); } +// --------------------------------------------------------------------------- +// Test 6: Button renderer — TableEvent struct is constructible; events_out ptr +// is accepted by the new render() overload (symbol resolution only). +// --------------------------------------------------------------------------- +static void test_button_column_spec_and_event_struct() { + TableInput t; + t.name = "t6"; + t.rows = 3; + t.cols = 4; + t.cells = g_cells; + t.headers = g_headers; + t.types = g_types; + + ColumnSpec cs_btn; + cs_btn.id = "actions"; + cs_btn.renderer = CellRenderer::Button; + cs_btn.button_action = "cancel"; + cs_btn.button_label = "Cancel"; + cs_btn.button_color_hex = "#ef4444"; + + t.column_specs.resize(4); + t.column_specs[0] = cs_btn; + + assert(t.column_specs[0].renderer == CellRenderer::Button); + assert(t.column_specs[0].button_action == "cancel"); + assert(t.column_specs[0].button_label == "Cancel"); + assert(t.column_specs[0].button_color_hex == "#ef4444"); + + // Verify TableEvent struct can be constructed and holds expected fields. + TableEvent ev; + ev.kind = TableEventKind::ButtonClick; + ev.row = 1; + ev.col = 0; + ev.column_id = "actions"; + ev.action_id = "cancel"; + ev.value = "ok"; + assert(ev.kind == TableEventKind::ButtonClick); + assert(ev.row == 1); + assert(ev.action_id == "cancel"); + + // Verify the render() overload with events_out is linkable. + std::vector events; + auto* render_with_events = static_cast&, + State&, + std::vector*, + bool)>(&data_table::render); + (void)render_with_events; + + std::printf("PASS: test_button_column_spec_and_event_struct " + "(Button spec + TableEvent + render overload link)\n"); +} + +// --------------------------------------------------------------------------- +// Test 7: Tooltip field — ColumnSpec with tooltip_on_hover=true. +// --------------------------------------------------------------------------- +static void test_tooltip_column_spec() { + TableInput t; + t.name = "t7"; + t.rows = 3; + t.cols = 4; + t.cells = g_cells; + t.headers = g_headers; + t.types = g_types; + + ColumnSpec cs_tip; + cs_tip.id = "status"; + cs_tip.renderer = CellRenderer::Text; // tooltip works on any renderer + cs_tip.tooltip = "auto"; // "auto" -> show cell value + cs_tip.tooltip_on_hover = true; + + t.column_specs.resize(4); + t.column_specs[0] = cs_tip; + + assert(t.column_specs[0].tooltip == "auto"); + assert(t.column_specs[0].tooltip_on_hover == true); + + // Also test explicit tooltip text. + ColumnSpec cs_tip2; + cs_tip2.id = "progress"; + cs_tip2.renderer = CellRenderer::Progress; + cs_tip2.tooltip = "Progress percentage (0..1)"; + cs_tip2.tooltip_on_hover = true; + t.column_specs[1] = cs_tip2; + + assert(t.column_specs[1].tooltip == "Progress percentage (0..1)"); + assert(t.column_specs[1].tooltip_on_hover == true); + + std::printf("PASS: test_tooltip_column_spec (auto + explicit, tooltip_on_hover=true)\n"); +} + +// --------------------------------------------------------------------------- +// Test 8: render() back-compat overload without events_out — symbol links. +// --------------------------------------------------------------------------- +static void test_render_backcompat_overload() { + // Verify both render() signatures are resolvable at link time. + // Classic (no events_out): + auto* render_classic = static_cast&, + State&, + bool)>(&data_table::render); + (void)render_classic; + + // New (with events_out): + auto* render_events = static_cast&, + State&, + std::vector*, + bool)>(&data_table::render); + (void)render_events; + + std::printf("PASS: test_render_backcompat_overload (both render() signatures link)\n"); +} + // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- @@ -191,6 +315,9 @@ int main() { test_progress_column_spec(); test_duration_column_spec(); test_icon_column_spec(); - std::printf("=== ALL TESTS PASSED (5/5) ===\n"); + test_button_column_spec_and_event_struct(); + test_tooltip_column_spec(); + test_render_backcompat_overload(); + std::printf("=== ALL TESTS PASSED (8/8) ===\n"); return 0; } diff --git a/cpp/tests/test_fn_table_viz_smoke.cpp b/cpp/tests/test_fn_table_viz_smoke.cpp new file mode 100644 index 00000000..e4d34469 --- /dev/null +++ b/cpp/tests/test_fn_table_viz_smoke.cpp @@ -0,0 +1,191 @@ +// test_fn_table_viz_smoke.cpp — Linker smoke test for fn_table_viz static lib. +// Issue 0081-I. Verifies that all 11 .cpp files in fn_table_viz resolve symbols +// at link time. Does NOT call data_table::render (requires ImGui context). +// +// Build: cmake --build cpp/build/linux --target test_fn_table_viz_smoke +// Run: ./cpp/build/linux/tests/test_fn_table_viz_smoke + +#include "core/compute_stage.h" +#include "core/compute_pipeline.h" +#include "core/tql_emit.h" +#include "core/tql_apply.h" +#include "core/lua_engine.h" +#include "core/join_tables.h" +#include "core/auto_detect_type.h" +#include "core/compute_column_stats.h" +#include "viz/viz_render.h" +#include "viz/data_table.h" + +#include +#include +#include +#include + +using namespace data_table; + +// --------------------------------------------------------------------------- +// Minimal input: 3 rows x 2 cols (string + numeric). +// --------------------------------------------------------------------------- +static const char* g_cells[] = { + "Alice", "10", + "Bob", "20", + "Carol", "30", +}; + +// --------------------------------------------------------------------------- +// Test 1: compute_stage with trivial Stage (no filters, no agg, no sort). +// --------------------------------------------------------------------------- +static void test_compute_stage_passthrough() { + Stage s; // empty stage = passthrough + std::vector hdrs = {"Name", "Value"}; + std::vector types = {ColumnType::String, ColumnType::Float}; + + StageOutput out = compute_stage(g_cells, 3, 2, hdrs, types, s); + assert(out.rows == 3 && "compute_stage: rows must be 3"); + assert(out.cols == 2 && "compute_stage: cols must be 2"); + std::printf("PASS: compute_stage passthrough (rows=%d cols=%d)\n", + out.rows, out.cols); +} + +// --------------------------------------------------------------------------- +// Test 2: auto_detect_type on the Value column (all numeric). +// --------------------------------------------------------------------------- +static void test_auto_detect_type() { + std::vector hdrs = {"Name", "Value"}; + std::vector types = {ColumnType::String, ColumnType::Float}; + // Detect type for column 1 (Value: "10","20","30" -> Float or Int) + ColumnType t = auto_detect_type(g_cells, 3, 2, /*col=*/1); + assert((t == ColumnType::Float || t == ColumnType::Int) && + "auto_detect_type: Value col should be Float or Int"); + std::printf("PASS: auto_detect_type numeric (%s)\n", + t == ColumnType::Float ? "Float" : "Int"); +} + +// --------------------------------------------------------------------------- +// Test 3: compute_column_stats on the Value column. +// --------------------------------------------------------------------------- +static void test_compute_column_stats() { + ColStats s = compute_column_stats(g_cells, 3, 2, /*col=*/1); + assert(s.numeric && "compute_column_stats: Value col should be numeric"); + assert(s.numeric_count == 3 && "compute_column_stats: 3 numeric values"); + assert(s.min < s.max && "compute_column_stats: min < max"); + std::printf("PASS: compute_column_stats (min=%.1f max=%.1f mean=%.1f)\n", + s.min, s.max, s.mean); +} + +// --------------------------------------------------------------------------- +// Test 4: tql_emit -> tql_apply round-trip on a trivial State. +// --------------------------------------------------------------------------- +static void test_tql_roundtrip() { + State st; + st.stages.push_back(Stage{}); + st.active_stage = 0; + st.display = ViewMode::Table; + + std::vector hdrs = {"Name", "Value"}; + std::vector types = {ColumnType::String, ColumnType::Float}; + + std::string lua_text = tql::emit(st, hdrs, types); + assert(!lua_text.empty() && "tql_emit: must produce non-empty Lua text"); + + auto res = tql::apply(lua_text, hdrs); + assert(res.ok && "tql_apply: round-trip must succeed"); + assert(!res.state.stages.empty() && "tql_apply: state must have stages"); + std::printf("PASS: tql_emit+tql_apply round-trip (ok=%s warnings=%zu)\n", + res.ok ? "true" : "false", res.warnings.size()); +} + +// --------------------------------------------------------------------------- +// Test 5: tql_apply extended overload (playground-compat bool signature). +// --------------------------------------------------------------------------- +static void test_tql_apply_extended() { + State st; + st.stages.push_back(Stage{}); + std::vector hdrs = {"Name", "Value"}; + std::vector types = {ColumnType::String, ColumnType::Float}; + std::string lua_text = tql::emit(st, hdrs, types); + + std::string err; + State out_st; + bool ok = tql::apply(lua_text, out_st, hdrs, types, g_cells, 3, 2, &err); + assert(ok && "tql_apply extended: must succeed"); + assert(!out_st.stages.empty() && "tql_apply extended: state must have stages"); + std::printf("PASS: tql_apply extended overload (ok=%s)\n", + ok ? "true" : "false"); +} + +// --------------------------------------------------------------------------- +// Test 6: lua_engine get + compile + release (verifies Lua 5.4 links). +// --------------------------------------------------------------------------- +static void test_lua_engine_compile() { + lua_engine::Engine* e = lua_engine::get(); + assert(e && "lua_engine::get must return non-null"); + std::string err; + int id = lua_engine::compile(e, "return row['Value'] * 2", &err); + assert(id >= 0 && "lua_engine::compile must succeed"); + lua_engine::release(e, id); + lua_engine::shutdown(); + std::printf("PASS: lua_engine compile + release (id=%d)\n", id); +} + +// --------------------------------------------------------------------------- +// Test 7: join_tables trivial self-join. +// --------------------------------------------------------------------------- +static void test_join_tables_trivial() { + std::vector hdrs = {"Name", "Value"}; + std::vector types = {ColumnType::String, ColumnType::Float}; + + TableInput right; + right.name = "right"; + right.headers = hdrs; + right.types = types; + right.cells = g_cells; + right.rows = 3; + right.cols = 2; + + Join j; + j.alias = "r"; + j.source = "right"; + j.on = {{"Name", "Name"}}; + j.strategy = JoinStrategy::Left; + + StageOutput out = join_tables(g_cells, 3, 2, hdrs, types, right, j); + assert(out.rows == 3 && "join_tables: self-join must produce 3 rows"); + std::printf("PASS: join_tables trivial self-join (rows=%d cols=%d)\n", + out.rows, out.cols); +} + +// --------------------------------------------------------------------------- +// Test 8: data_table::render symbol is linkable (Wave 3.5 — issue 0081-I). +// Does NOT call render (requires ImGui context); just takes its address. +// --------------------------------------------------------------------------- +static void test_data_table_render_links() { + // Taking the address of render verifies the linker resolved the symbol + // from data_table.cpp inside fn_table_viz. No ImGui context needed. + // Use the events_out overload (Phase 2) as the canonical full-signature check. + auto* render_fn = static_cast&, + data_table::State&, + std::vector*, + bool)>(&data_table::render); + (void)render_fn; + std::printf("PASS: data_table::render symbol links (address=%p)\n", + reinterpret_cast(render_fn)); +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +int main() { + std::printf("=== test_fn_table_viz_smoke ===\n"); + test_compute_stage_passthrough(); + test_auto_detect_type(); + test_compute_column_stats(); + test_tql_roundtrip(); + test_tql_apply_extended(); + test_lua_engine_compile(); + test_join_tables_trivial(); + test_data_table_render_links(); + std::printf("=== ALL TESTS PASSED ===\n"); + return 0; +} diff --git a/docs/capabilities/data_table_renderers.md b/docs/capabilities/data_table_renderers.md index 738ec043..7c2baf53 100644 --- a/docs/capabilities/data_table_renderers.md +++ b/docs/capabilities/data_table_renderers.md @@ -1,21 +1,25 @@ -# data_table_renderers — declarative cell renderers (v1.1.0) +# data_table_renderers — declarative cell renderers (v1.2.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. +Badge, Progress, Duration, Icon y **Button** (Phase 2), emitir eventos de interaccion +(ButtonClick, RowDoubleClick, RowRightClick), mostrar tooltips por celda y persistir +los specs en TQL (`aux_column_specs` roundtrip). Back-compat 100%: apps sin +`column_specs` ni `events_out` no necesitan cambios. -## Tipos nuevos en `data_table_types.h` +## Tipos nuevos / actualizados en `data_table_types.h` | Tipo | Que es | |---|---| -| `CellRenderer` | enum class: `Text=0`, `Badge=1`, `Progress=2`, `Duration=3`, `Icon=4` | +| `CellRenderer` | enum class: `Text=0`, `Badge=1`, `Progress=2`, `Duration=3`, `Icon=4`, **`Button=5`** | | `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 | +| `ColumnSpec` | id + renderer + badges / progress / duration / icon_map / **button_action, button_label, button_color_hex** / **tooltip, tooltip_on_hover** | | `TableInput::column_specs` | `std::vector` sidecar opcional (size 0 o == cols) | +| **`TableEventKind`** | enum class: ButtonClick=1, RowDoubleClick=2, RowRightClick=3, CellEdit=4 (reservado) | +| **`TableEvent`** | kind + row + col + column_id + action_id + value | +| **`State::aux_column_specs`** | specs persistidos en TQL (roundtrip via tql_emit/tql_apply) | ## Ejemplo canonico: Recent Executions (status Badge + duration Duration) @@ -107,21 +111,78 @@ TI_COPY TI_EXTERNAL_LINK Si el `icon_name` no esta en la tabla, la celda se renderiza como texto plano. +## Ejemplo Phase 2: Button + events + tooltip + +```cpp +// --- Setup --- +t.column_specs.resize(t.cols); + +// Columna "actions": boton Cancel por fila +t.column_specs[col_actions].renderer = data_table::CellRenderer::Button; +t.column_specs[col_actions].button_action = "cancel"; +t.column_specs[col_actions].button_label = "Cancel"; +t.column_specs[col_actions].button_color_hex = "#ef4444"; + +// Columna "status": tooltip automatico (muestra valor truncado) +t.column_specs[col_status].tooltip = "auto"; +t.column_specs[col_status].tooltip_on_hover = true; + +// --- Render loop --- +events.clear(); +data_table::render("##t", {t}, st, &events); + +// --- Procesar eventos --- +for (const auto& ev : events) { + if (ev.kind == data_table::TableEventKind::ButtonClick && + ev.action_id == "cancel") { + cancel_row(ev.row); + } + if (ev.kind == data_table::TableEventKind::RowDoubleClick) { + open_detail(ev.row); + } + if (ev.kind == data_table::TableEventKind::RowRightClick) { + // Abrir menu propio via ImGui::BeginPopup + ctx_menu_row = ev.row; + ImGui::OpenPopup("##ctx"); + } +} +``` + +## Ejemplo Phase 2: TQL roundtrip de column_specs + +```cpp +// Persistir specs en State.aux_column_specs para guardar en .tql +data_table::ColumnSpec cs; +cs.id = "status"; cs.renderer = data_table::CellRenderer::Badge; +cs.badges = {{"ok","#22c55e","OK"},{"error","#ef4444",""}}; +st.aux_column_specs = {{cs}}; // [tabla_0: {spec_col_0}] + +// tql_emit serializa aux_column_specs automaticamente +std::string tql = tql::emit(st, headers, types); +// tql_apply lo recupera en res.state.aux_column_specs +auto res = tql::apply(tql, headers); +// render() lo aplica si TableInput.column_specs esta vacio +data_table::render("##t", {t}, res.state, &events); +``` + ## 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). +- **Solo Column 0..N posicional**: `column_specs[i]` aplica a la columna en posicion `i` del `TableInput` original. No se mapea por nombre. +- **Button con celda vacia**: si el cell value es vacio, NO se dibuja boton. Poner un valor no vacio en la celda para habilitar el boton. +- **No implementa TextInput/Custom** (Phase 3 separado). - **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. +- **RowRightClick**: en el raw table (stage 0) el evento se emite pero el popup de drill-down interno sigue abriendose. La app puede abrir su propio popup al detectar el evento. ## 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). +- `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 explicitoo). - `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. +- `events_out` no se limpia en `render()` — el caller debe llamar `events.clear()` antes de cada frame. +- `aux_column_specs` solo se persiste para `tables[0]` (el main table). Specs para tablas extra deben gestionarse por el caller. ## 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). +- Tests: `cpp/tests/test_column_specs.cpp` (8 tests: 1 back-compat + 4 renderer types + 3 Phase 2). Smoke/linker; no requieren ImGui context. +- TQL roundtrip implementado en Phase 2 (issue 0081-O, v1.2.0): `tql_emit_test` (3 tests) + `tql_apply_test` (9 tests nuevos). From 0ed949d235120435a7d76e2a99d8f32850bd3b6c Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 17:01:06 +0200 Subject: [PATCH 10/18] fix(dag_engine_ui): gitlink Win32 ws2_32 link (issue 0095) Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/apps/dag_engine_ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/apps/dag_engine_ui b/cpp/apps/dag_engine_ui index 7a38fe9a..7c7923ac 160000 --- a/cpp/apps/dag_engine_ui +++ b/cpp/apps/dag_engine_ui @@ -1 +1 @@ -Subproject commit 7a38fe9a4192898c1d797362640282c644f6c64f +Subproject commit 7c7923ac6a2d15a86257f6ca9ec7c0b1ecadbac9 From ae5d27a5ec0b1e94b113a03d80540a4ce2a1308f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 17:07:16 +0200 Subject: [PATCH 11/18] feat(dag_engine): /api/dags devuelve last_runs[] (max 5) + gitlink UI badges - executor.go: DagInfo anade LastRuns []store.DagRun. Pobla con e.store.ListRuns(name, 5, 0). - cpp/apps/dag_engine_ui: gitlink al SHA con 5 puntitos R1..R5 via data_table BadgeRule. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/dag_engine/executor.go | 22 ++++++++++++---------- cpp/apps/dag_engine_ui | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/dag_engine/executor.go b/apps/dag_engine/executor.go index 44fd9ee9..a14a90ec 100644 --- a/apps/dag_engine/executor.go +++ b/apps/dag_engine/executor.go @@ -326,14 +326,15 @@ func generateID() string { // DagInfo summarizes a DAG file for listing. type DagInfo struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Schedule []string `json:"schedule,omitempty"` - Tags []string `json:"tags,omitempty"` - Type string `json:"type,omitempty"` - FilePath string `json:"file_path"` - Valid bool `json:"valid"` - LastRun *store.DagRun `json:"last_run,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Schedule []string `json:"schedule,omitempty"` + Tags []string `json:"tags,omitempty"` + Type string `json:"type,omitempty"` + FilePath string `json:"file_path"` + Valid bool `json:"valid"` + LastRun *store.DagRun `json:"last_run,omitempty"` + LastRuns []store.DagRun `json:"last_runs,omitempty"` // 5 mas recientes (mas reciente primero) } // ListDAGs scans a directory for YAML files and returns parsed DAG info. @@ -379,10 +380,11 @@ func (e *Executor) ListDAGs() ([]DagInfo, error) { Valid: true, } - // Attach last run info. - runs, _, _ := e.store.ListRuns(dag.Name, 1, 0) + // Attach last 5 runs (most recent first). + runs, _, _ := e.store.ListRuns(dag.Name, 5, 0) if len(runs) > 0 { info.LastRun = &runs[0] + info.LastRuns = runs } dags = append(dags, info) diff --git a/cpp/apps/dag_engine_ui b/cpp/apps/dag_engine_ui index 7c7923ac..49fc908f 160000 --- a/cpp/apps/dag_engine_ui +++ b/cpp/apps/dag_engine_ui @@ -1 +1 @@ -Subproject commit 7c7923ac6a2d15a86257f6ca9ec7c0b1ecadbac9 +Subproject commit 49fc908fb4d07361f63715de800a1a4044851b4e From 890f6416921da808cc03544f3d5c56a328077799 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 17:14:16 +0200 Subject: [PATCH 12/18] feat(dag_engine_ui): gitlink panel Timeline (ImPlot scatter X=tiempo Y=DAG) Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/apps/dag_engine_ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cpp/apps/dag_engine_ui b/cpp/apps/dag_engine_ui index 49fc908f..4cb36a92 160000 --- a/cpp/apps/dag_engine_ui +++ b/cpp/apps/dag_engine_ui @@ -1 +1 @@ -Subproject commit 49fc908fb4d07361f63715de800a1a4044851b4e +Subproject commit 4cb36a92e826610a61c06bd6bca379518e1c61d4 From 67fff0d677e814254f929626263242fd4438ea1c Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 17:23:33 +0200 Subject: [PATCH 13/18] docs(dag_engine): README autoritativo (anadir DAGs + formato YAML + troubleshooting) apps/dag_engine/README.md cubre: - Donde viven los DAGs y como apuntar el systemd unit. - Workflow paso a paso para anadir uno nuevo (crear/validar/probar/recargar/verificar). - Formato YAML completo: top-level fields + step fields + cron schedule + ejemplo de extremo a extremo (env, depends, retry_policy, continue_on, handlers). - Comandos CLI (run/list/status/validate/server) + flags. - 7 secciones de "que hacer si algo falla": DAG invisible, validation fail, step fallido, scheduler no dispara, WS disconnected, cleanup runs viejos, restaurar backup. - Endpoints HTTP completos. - Referencias a funciones del registry y commit de migracion. app.md de dag_engine + dag_engine_ui apuntan a README.md. gitlink dag_engine_ui actualizado a commit con app.md mejorado. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/dag_engine/README.md | 335 ++++++++++++++++++++++++++++++++++++++ apps/dag_engine/app.md | 5 + cpp/apps/dag_engine_ui | 2 +- 3 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 apps/dag_engine/README.md diff --git a/apps/dag_engine/README.md b/apps/dag_engine/README.md new file mode 100644 index 00000000..13715362 --- /dev/null +++ b/apps/dag_engine/README.md @@ -0,0 +1,335 @@ +# dag_engine — Guia de uso + +Motor de DAGs propio (reemplazo de Dagu). Backend Go + frontend web (Vite/React) + frontend C++ ImGui (`cpp/apps/dag_engine_ui`). + +Doc canonica para **anadir DAGs**, **formato YAML**, **comandos CLI**, y **diagnostico de fallos**. + +--- + +## 1. Donde viven los DAGs + +| Path | Que | +|---|---| +| `apps/dag_engine/dags_migrated/` | DAGs activos servidos por `dag_engine.service` (systemd user unit). | +| `~/dagu/dags/` | Path por defecto del binario si no se pasa `--dags-dir`. Vacio tras la migracion del 2026-05-15 (ver tag `dagu_pre_removal`). | +| `~/backups/dagu_pre_removal_.tar.gz` | Backup completo de la carpeta dagu antes de borrar. | + +Por defecto el systemd unit apunta a `apps/dag_engine/dags_migrated/`. Para usar otro dir, edita `~/.config/systemd/user/dag_engine.service`: + +```ini +ExecStart=/home/lucas/fn_registry/apps/dag_engine/dag_engine server \ + --port 8090 \ + --dags-dir /home/lucas/fn_registry/apps/dag_engine/dags_migrated \ + --db /home/lucas/fn_registry/apps/dag_engine/dag_engine.db \ + --scheduler +``` + +Y reload + restart: +```bash +systemctl --user daemon-reload +systemctl --user restart dag_engine.service +``` + +--- + +## 2. Anadir un DAG nuevo (workflow) + +### Paso a paso + +1. **Crear YAML** en `apps/dag_engine/dags_migrated/.yaml` (ver formato en seccion 3). +2. **Validar** sin ejecutar: + ```bash + ./apps/dag_engine/dag_engine validate apps/dag_engine/dags_migrated/.yaml + ``` + Salida esperada: `Validation: PASS`. Si falla, ver seccion 5 (diagnostico). +3. **Probar ejecucion manual** una vez: + ```bash + ./apps/dag_engine/dag_engine run apps/dag_engine/dags_migrated/.yaml + ``` +4. **Recargar scheduler** (toma el YAML automaticamente al iterar el dir): + ```bash + systemctl --user restart dag_engine.service + journalctl --user-unit dag_engine.service -n 30 --no-pager + ``` + Busca la linea `[scheduler] ticker started for ()` en los logs. +5. **Verificar en frontend**: + - C++ ImGui: panel `DAGs` muestra el nuevo DAG. Pulsa `Refresh` si no aparece. + - Web: `http://localhost:8090`. + +### Disparo manual desde curl o frontend + +```bash +curl -X POST http://127.0.0.1:8090/api/dags//run +``` + +Devuelve `{"dag":"","run_id":"...","status":"accepted"}` y dispara el WS broadcast — los frontends ven la run en `<1s`. + +--- + +## 3. Formato YAML + +dag_engine es **compatible con el formato Dagu**. Los YAMLs heredados de `~/dagu/dags/` validan sin modificaciones. + +### Ejemplo completo + +```yaml +name: my_pipeline +description: "Pipeline diario que importa CSV y actualiza Metabase." +group: finanzas # opcional, agrupa DAGs en listados +type: graph # opcional: graph (default) | chain +tags: [daily, csv, metabase] # opcional, filtros en la UI + +# Variables de entorno (heredadas por todos los steps). +env: + - DATA_DIR: /home/lucas/data + - SLACK_HOOK: ${SLACK_HOOK_PROD} # interpolacion de ENV del host + +# Cron schedule. Puede ser string o lista. +schedule: + - "0 9 * * *" # 09:00 todos los dias + - "0 21 * * 5" # 21:00 viernes (segundo trigger) + +# Working dir + shell por defecto para todos los steps. +working_dir: /home/lucas/fn_registry +shell: /bin/bash +timeout_sec: 1800 # 30 min para todo el DAG + +steps: + - name: ingest + description: "Descarga CSV." + command: ./bash/functions/pipelines/ingest_csv.sh + timeout_sec: 300 # 5 min para este step + env: + - SOURCE_URL: https://example.com/data.csv + + - name: transform + description: "Limpieza y agregacion." + script: | + #!/usr/bin/env python3 + import pandas as pd + df = pd.read_csv("$DATA_DIR/raw.csv") + df.to_parquet("$DATA_DIR/clean.parquet") + depends: [ingest] # debe terminar OK antes + retry_policy: + limit: 2 # reintentos en caso de fallo + interval_sec: 60 + + - name: load_metabase + command: ./bash/functions/metabase/refresh_dashboard.sh + depends: [transform] + continue_on: + failure: true # no aborta el DAG aunque falle + + - name: notify + command: ./bash/functions/io/slack_send.sh "pipeline OK" + depends: [load_metabase] + +# Hooks de ciclo de vida. +handler_on: + success: ./bash/functions/io/notify_success.sh + failure: ./bash/functions/io/notify_failure.sh + exit: ./bash/functions/io/cleanup.sh +``` + +### Campos del DAG (top-level) + +| Campo | Tipo | Default | Que | +|---|---|---|---| +| `name` | string | (obligatorio) | Identificador unico. Debe matchear el filename sin extension. | +| `description` | string | "" | Texto libre, aparece en la UI. | +| `group` | string | "" | Agrupa DAGs en listados. | +| `type` | string | `""` (graph) | `graph` o `chain`. graph = grafo dirigido por `depends`. chain = ejecucion secuencial implicita. | +| `working_dir` | string | cwd del server | Path absoluto desde donde lanzar los steps. | +| `shell` | string | `/bin/sh` | Shell para `command:`. | +| `env` | list/map | [] | Variables de entorno DAG-wide. | +| `schedule` | string/list | "" | Cron expressions (5 campos: min hour dom mon dow). Vacio = solo manual. | +| `steps` | list | (obligatorio) | Pasos del DAG (>=1). | +| `handler_on` | map | null | Hooks `init/success/failure/exit`. Alias: `handlers`. | +| `tags` | list[string] | [] | Filtros en la UI. | +| `timeout_sec` | int | 0 (sin timeout) | Timeout global del DAG en segundos. | + +### Campos de cada step + +| Campo | Tipo | Default | Que | +|---|---|---|---| +| `name` | string | (obligatorio) | Identificador del step dentro del DAG. | +| `id` | string | "" | Override del id auto-generado. | +| `description` | string | "" | Texto libre. | +| `command` | string | "" | Comando shell (mutuamente excluyente con `script`). | +| `script` | string | "" | Bloque heredoc. Util para Python/Lua inline. | +| `args` | list[string] | [] | Args extra para `command`. | +| `shell` | string | hereda | Override del shell. | +| `dir` / `working_dir` | string | hereda | Working dir para este step. | +| `depends` | list[string] | [] | Steps que deben terminar OK antes. Si vacio + `type:graph`, arranca en paralelo. | +| `env` | list/map | hereda | Env del step (sobrescribe el del DAG). | +| `continue_on.failure` | bool | false | Si true, el DAG sigue aunque este step falle. | +| `continue_on.skipped` | bool | false | Si true, dependientes corren aunque este step quede skipped. | +| `retry_policy.limit` | int | 0 | Reintentos. | +| `retry_policy.interval_sec` | int | 0 | Segundos entre reintentos. | +| `timeout_sec` | int | 0 (sin timeout) | Timeout del step. | +| `output` | string | "" | Nombre de variable donde guardar stdout (consumible por dependientes). | +| `tags` | list[string] | [] | Tags por step (UI). | + +### Cron schedule + +5 campos clasicos: `min hour dom mon dow`. Ejemplos: + +| Expresion | Significado | +|---|---| +| `0 9 * * *` | Todos los dias a las 09:00 | +| `*/15 * * * *` | Cada 15 minutos | +| `0 */6 * * *` | Cada 6 horas en punto | +| `0 9 * * 1-5` | 09:00 lunes-viernes | +| `0 21 * * 5` | 21:00 viernes | + +Multiples cron en `schedule:` -> el DAG dispara por cada uno. + +--- + +## 4. Comandos CLI + +```bash +./dag_engine run # ejecuta un DAG ad-hoc +./dag_engine list [dir] # lista DAGs con schedule + ultimo status +./dag_engine status [dag_name] # historial de ejecuciones +./dag_engine validate # parse + validate (no ejecuta) +./dag_engine server # arranca HTTP + WS hub + frontend embebido +``` + +Flags del `server`: + +| Flag | Default | Que | +|---|---|---| +| `--port` | 8090 | Puerto HTTP. | +| `--dags-dir` | `~/dagu/dags` | Dir scaneado para YAMLs. | +| `--db` | `dag_engine.db` | SQLite con `dag_runs` + `dag_step_results`. | +| `--scheduler` | false | Si presente, arranca cron tickers automaticamente. | + +--- + +## 5. Que hacer si algo falla + +### 5.1. El DAG no aparece en la UI + +**Sintoma:** anadiste un YAML pero `GET /api/dags` no lo lista. + +| Causa | Diagnostico | Fix | +|---|---|---| +| YAML invalido | `./dag_engine validate ` muestra el error. | Corregir segun el mensaje (campo desconocido, indentacion, type wrong). | +| Filename con extension fuera de `.yaml`/`.yml` | `ls apps/dag_engine/dags_migrated/` | Renombrar a `.yaml`. | +| El servidor apunta a otro dir | `systemctl --user cat dag_engine.service` -> ver `--dags-dir`. | Ajustar unit y `daemon-reload + restart`. | +| Cache UI antiguo | C++: pulsa `Refresh`. Web: `Ctrl+F5`. | — | + +### 5.2. Validation: FAIL + +`validate` muestra `parse error: ...` o `Validation: FAIL`. Causas tipicas: + +| Mensaje | Causa | Fix | +|---|---|---| +| `yaml unmarshal: ...` | Sintaxis YAML rota (indentacion, tab vs espacios). | Usar 2 espacios consistentes. Validar online con `yamllint`. | +| `dag_parse: step[N]: name is required` | Step sin `name:`. | Anadir `name:`. | +| `dag_parse: step[N]: command or script required` | Step sin `command` ni `script`. | Anadir uno de los dos. | +| `cycle detected: A -> B -> A` | `depends` forma ciclo. | Romper la dependencia o convertir uno de los nodos en step distinto. | +| `unknown depends: ` | `depends:` referencia un step inexistente. | Comprobar nombres exactos (case-sensitive). | +| `invalid cron: ` | Cron mal formado (4 o 6 campos en vez de 5). | Verificar `0 9 * * *` (5 campos). | + +### 5.3. El DAG corre pero un step falla + +**Sintoma:** `status: failed` en la UI. + +1. Abre `DAG Detail` y haz doble-click en el run rojo -> `Run Detail`. +2. Expande el step que fallo (CollapsingHeader). Muestra `stdout` + `stderr`. +3. Errores tipicos: + +| stderr | Causa | Fix | +|---|---|---| +| `command not found` | `command:` apunta a un binario fuera de `PATH`. | Path absoluto o setear `env: [PATH: ...]`. | +| `permission denied` | Script sin `chmod +x`. | `chmod +x