data_table: Phase 2 — Button + events + tooltip + RightClick + TQL persist column_specs (issue 0081-O)

- CellRenderer::Button=5: renders SmallButton per cell; emits TableEvent::ButtonClick on click
- TableEventKind enum (ButtonClick/RowDoubleClick/RowRightClick/CellEdit) + TableEvent struct
- render() extended overload: adds events_out parameter (nullptr = back-compat, no events)
- RowDoubleClick and RowRightClick detection in raw table loop (stage 0)
- RowRightClick also detected in aggregated stage table (stage 1+)
- Tooltip per cell: tooltip_on_hover + tooltip fields on ColumnSpec; "auto" = show cell value
- State::aux_column_specs: TQL-persisted column specs sidecar per table
- tql_emit: serializes aux_column_specs[0] as column_specs block (badge/progress/duration/icon/button/tooltip)
- tql_apply: parses column_specs block back into state.aux_column_specs[0]
- render() merges aux_column_specs into TableInput when caller passes empty column_specs
- test_column_specs: 5->8 tests (Button struct, tooltip fields, both render() signatures link)
- tql_emit_test: 3 new tests (column_specs badge/button/tooltip emit) — 52 passed
- tql_apply_test: 3 new tests (column_specs badge/button/tooltip roundtrip) — 106 passed
- Back-compat: existing apps (graph_explorer, registry_dashboard) unchanged
- Version bump: data_table v1.1.0 -> v1.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 16:59:26 +02:00
parent 1c9aabd212
commit 97bd5ea056
11 changed files with 2593 additions and 40 deletions
+136 -13
View File
@@ -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<TableEvent>* 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<TableInput>& tables,
State& st,
std::vector<TableEvent>* 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<const char*> 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) {