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).