diff --git a/views.cpp b/views.cpp index 7aedb7b..48f50b2 100644 --- a/views.cpp +++ b/views.cpp @@ -6,6 +6,8 @@ #include "viz/graph_viewport.h" #include "viz/graph_sources.h" +#include "../../../../cpp/vendor/sqlite3/sqlite3.h" + #include "core/button.h" #include "core/icon_button.h" #include "core/toolbar.h" @@ -294,6 +296,11 @@ void views_toolbar(AppState& app) { app.show_open_modal = true; } ImGui::SameLine(); + if (button(TI_TABLE " Import dataset...", ButtonVariant::Secondary)) { + app.show_import_modal = true; + app.import_error.clear(); + } + ImGui::SameLine(); // Add node — input + auto-deteccion de tipo. Enter o boton "Add" lo // confirman; main.cpp inserta en operations.db y dispara reload. @@ -1599,6 +1606,250 @@ void views_table(AppState& app) { ImGui::End(); } +// ---------------------------------------------------------------------------- +// Table node UI fase 2 (issue 0011) — ventana expandida + import +// ---------------------------------------------------------------------------- + +void views_table_windows_sync(AppState& app, const char* ops_db) { + if (!app.graph || !ops_db) return; + GraphData& g = *app.graph; + + // Construir set de Tables expandidas con su metadata fresca. + std::unordered_map live; + for (int i = 0; i < g.node_count; ++i) { + const GraphNode& n = g.nodes[i]; + if (n.type_id >= (uint16_t)g.type_count) continue; + const EntityType& t = g.types[n.type_id]; + if (!t.name || std::strcmp(t.name, "Table") != 0) continue; + // Resolver entity_id via SQL inverso por user_data hash es caro; + // hacemos una pasada SQL para todas las Table entities. + } + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return; + } + const char* sql = + "SELECT id FROM entities " + "WHERE type_ref = 'Table' AND json_extract(metadata,'$.expanded') = json('true')"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return; + } + while (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* p = sqlite3_column_text(st, 0); + if (!p) continue; + std::string id = (const char*)p; + TableMetadata meta; + if (tableview_get_metadata(ops_db, id.c_str(), &meta)) { + live.emplace(id, std::move(meta)); + } + } + sqlite3_finalize(st); + sqlite3_close(db); + + // Quitar las que ya no estan expanded. + for (auto it = app.table_windows.begin(); it != app.table_windows.end(); ) { + if (live.find(it->first) == live.end()) it = app.table_windows.erase(it); + else ++it; + } + // Anadir las nuevas o refrescar metadata. + for (auto& kv : live) { + auto& w = app.table_windows[kv.first]; + bool was_present = !w.meta.entity_id.empty(); + w.meta = std::move(kv.second); + w.open = true; + if (!was_present) { + w.offset = 0; + w.page.clear(); + w.total_rows = 0; + w.page_dirty = true; + } + } +} + +void views_table_window(AppState& app) { + if (app.table_windows.empty()) return; + GraphData* g = app.graph; + GraphViewportState* vp = app.viewport; + + for (auto& kv : app.table_windows) { + TableMetadata& m = kv.second.meta; + AppState::TableWindowState& w = kv.second; + + char title[160]; + std::snprintf(title, sizeof(title), TI_TABLE " %s##te_%s", + m.name.empty() ? "Table" : m.name.c_str(), + m.entity_id.c_str()); + ImGui::SetNextWindowSize(ImVec2(640, 460), ImGuiCond_FirstUseEver); + if (!ImGui::Begin(title, &w.open)) { ImGui::End(); continue; } + + // Header de info + ImGui::TextDisabled("%s · %s · %lld rows", + m.duckdb_path.c_str(), m.table_name.c_str(), + (long long)w.total_rows); + ImGui::Separator(); + + // Tabla + const int col_count = (int)m.columns.size() + 2; // id + columns... + promoted + ImGuiTableFlags tflags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | + ImGuiTableFlags_SizingStretchProp; + if (ImGui::BeginTable("##te_rows", col_count, tflags, + ImVec2(0, -ImGui::GetFrameHeightWithSpacing()))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn(m.id_column.empty() ? "id" : m.id_column.c_str(), + ImGuiTableColumnFlags_WidthFixed, 100.0f); + for (const auto& c : m.columns) { + ImGui::TableSetupColumn(c.c_str(), ImGuiTableColumnFlags_WidthStretch); + } + ImGui::TableSetupColumn("promoted", + ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableHeadersRow(); + + // Decidir paginacion por scroll: pedimos siempre 200 filas a + // partir de offset; si el usuario llega cerca del final, + // avanzamos offset. + const int64_t page_size = 200; + for (int64_t i = 0; i < (int64_t)w.page.size(); ++i) { + const TablePageRow& row = w.page[i]; + ImGui::TableNextRow(); + ImGui::PushID((int)(w.offset + i)); + + ImGui::TableSetColumnIndex(0); + bool is_promoted = !row.promoted_entity_id.empty(); + ImGui::TextUnformatted(row.id.c_str()); + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { + if (is_promoted) { + app.want_focus_entity = true; + app.focus_entity_id = row.promoted_entity_id; + } else { + app.want_promote_row = true; + app.promote_table_id = m.entity_id; + app.promote_row_id = row.id; + } + } + if (ImGui::BeginPopupContextItem("##trowctx")) { + if (is_promoted) { + if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) { + app.want_focus_entity = true; + app.focus_entity_id = row.promoted_entity_id; + } + if (ImGui::MenuItem(TI_X " Demote (delete entity)")) { + app.want_demote_entity = true; + app.demote_entity_id = row.promoted_entity_id; + } + } else { + if (ImGui::MenuItem(TI_PLUS " Promote to graph node")) { + app.want_promote_row = true; + app.promote_table_id = m.entity_id; + app.promote_row_id = row.id; + } + } + ImGui::EndPopup(); + } + + for (size_t c = 0; c < m.columns.size(); ++c) { + ImGui::TableSetColumnIndex(1 + (int)c); + if (c < row.values.size()) + ImGui::TextUnformatted(row.values[c].c_str()); + } + ImGui::TableSetColumnIndex(col_count - 1); + if (is_promoted) { + ImGui::PushStyleColor(ImGuiCol_Text, + ImVec4(0.6f, 0.95f, 0.6f, 1.0f)); + ImGui::TextUnformatted("yes"); + ImGui::PopStyleColor(); + } else { + ImGui::TextDisabled("-"); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + + // Footer: paginacion manual (offset). + bool has_prev = w.offset > 0; + bool has_next = w.offset + (int64_t)w.page.size() < w.total_rows; + if (!has_prev) ImGui::BeginDisabled(); + if (fn_ui::button(TI_ARROW_LEFT " Prev", fn_ui::ButtonVariant::Subtle)) { + w.offset = std::max(0, w.offset - 200); + w.page_dirty = true; + } + if (!has_prev) ImGui::EndDisabled(); + ImGui::SameLine(); + if (!has_next) ImGui::BeginDisabled(); + if (fn_ui::button("Next " TI_ARROW_RIGHT, fn_ui::ButtonVariant::Subtle)) { + w.offset = w.offset + 200; + w.page_dirty = true; + } + if (!has_next) ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::TextDisabled("rows %lld-%lld of %lld", + (long long)w.offset + (w.page.empty() ? 0 : 1), + (long long)(w.offset + (int64_t)w.page.size()), + (long long)w.total_rows); + ImGui::SameLine(); + if (fn_ui::button(TI_REFRESH " Reload", fn_ui::ButtonVariant::Subtle)) { + w.page_dirty = true; + } + + ImGui::End(); + + (void)g; (void)vp; + } + + // Cerrar la ventana = expanded=false. Lo procesa main.cpp leyendo + // table_windows y comparando `open`. +} + +bool views_import_dataset_modal(AppState& app) { + if (!app.show_import_modal) return false; + bool submitted = false; + if (fn_ui::modal_dialog_begin("Import dataset", &app.show_import_modal, + ImVec2(560, 0))) { + ImGui::TextWrapped( + "Crea una nueva tabla DuckDB importando un fichero CSV/Parquet/JSON. " + "Tras el import, se anade un nodo Table apuntando a la nueva tabla."); + ImGui::Spacing(); + fn_ui::text_input("File path", + app.import_path_buf, sizeof(app.import_path_buf), + "tables/people.csv"); + fn_ui::text_input("DuckDB path", + app.import_duckdb_buf, sizeof(app.import_duckdb_buf), + "tables/people.duckdb"); + fn_ui::text_input("Dest table", + app.import_table_buf, sizeof(app.import_table_buf), + "people"); + fn_ui::text_input("Row type", + app.import_row_type_buf,sizeof(app.import_row_type_buf), + "Person"); + if (!app.import_error.empty()) { + ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", + app.import_error.c_str()); + } + ImGui::Spacing(); + if (fn_ui::button("Import", fn_ui::ButtonVariant::Primary)) { + if (app.import_path_buf[0] && app.import_duckdb_buf[0] + && app.import_table_buf[0]) { + app.want_import = true; + submitted = true; + } else { + app.import_error = "File path, DuckDB path y dest table son obligatorios."; + } + } + ImGui::SameLine(); + if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) { + app.show_import_modal = false; + app.import_error.clear(); + } + } + fn_ui::modal_dialog_end(); + return submitted; +} + // ---------------------------------------------------------------------------- // Table node overlay (issue 0010) // ---------------------------------------------------------------------------- diff --git a/views.h b/views.h index ea8b06b..5ad581a 100644 --- a/views.h +++ b/views.h @@ -5,6 +5,7 @@ #include "types_registry.h" #include "entity_ops.h" +#include "tableview.h" #include #include @@ -145,6 +146,45 @@ struct AppState { // Refrescado tras load_input y tras mutaciones que afecten a Tables. std::unordered_map table_node_counts; + // ---- Table node UI fase 2 (issue 0011) -------------------------------- + // Estado runtime por ventana de Table expandida. Una entrada por + // entity_id de Table que el usuario haya expandido. La ventana se cierra + // cuando set_expanded(false) — ya sea desde context menu o cerrando la + // ImGui window (que pone el flag a false automaticamente). + struct TableWindowState { + TableMetadata meta; // refrescada cada vez que entity cambia + int64_t total_rows = 0; + int64_t offset = 0; + std::vector page; + bool page_dirty = true; + bool open = true; // bound a ImGui::Begin + }; + std::unordered_map table_windows; + + // Triggers consumidos por main.cpp tras click en filas. + bool want_promote_row = false; + std::string promote_table_id; // entity_id del Table de origen + std::string promote_row_id; // valor del id_column + + bool want_demote_entity = false; + std::string demote_entity_id; + + bool want_focus_entity = false; // tras promote+open inspector + std::string focus_entity_id; + + // Modal "Import dataset..." (issue 0011 Ingesta). + bool show_import_modal = false; + char import_path_buf[512] = {}; + char import_table_buf[64] = {}; + char import_duckdb_buf[256] = {}; // relativo a project root + char import_row_type_buf[64] = {}; + bool want_import = false; + std::string import_error; + + // Toggle expanded desde context menu del viewport. + bool want_toggle_expanded = false; + std::string toggle_expanded_id; + // ---- Table view (issue 0004) ------------------------------------------- // Vista tabular dockeable. Tabs por type_ref del grafo activo + opcional // "All". Click selecciona el nodo en el viewport (mismo flujo que el @@ -251,6 +291,25 @@ EntityRecord views_inspector_build_record(const AppState& app); // al cambiar de proyecto. void views_inspector_clear_draft(AppState& app); +// ---- Table node UI fase 2 (issue 0011) ---------------------------------- + +// Renderiza una ventana ImGui dockeable por cada Table en table_windows +// con `open=true`. Cabecera con nombres de columnas. Filas paginadas con +// ImGuiListClipper consumiendo el page cache; al cambiar el offset, marca +// dirty para que main.cpp refresque via tableview_page. Doble click en +// fila no promovida -> setea promote_table_id/promote_row_id; promovida +// -> focus_entity_id. Cerrar la ventana setea expanded=false en BD. +void views_table_window(AppState& app); + +// Modal "Import dataset..." — formulario para crear una tabla DuckDB +// desde CSV/Parquet/JSON y registrar el nodo Table correspondiente. +bool views_import_dataset_modal(AppState& app); + +// Sincroniza table_windows con la metadata.expanded de cada nodo Table. +// Llamar tras load + tras mutaciones que cambien expanded. Crea entradas +// para nuevos expanded y borra las que ya no aplican. +void views_table_windows_sync(AppState& app, const char* ops_db); + // ---- Table node overlay (issue 0010) ------------------------------------ // Dibuja un overlay rectangulo redondeado sobre cada nodo `Table` del grafo