#include "views.h" #include "entity_ops.h" #include "project_manager.h" #include "viz/graph_types.h" #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" #include "core/select.h" #include "core/modal_dialog.h" #include "core/text_input.h" #include "core/tokens.h" #include "core/icons_tabler.h" #include "imgui.h" #include #include #include #include #include #include #include namespace ge { namespace { const char* k_layout_names[] = { "force", "grid", "circular", "radial", "hierarchical", "fixed", }; constexpr int k_layout_count = (int)(sizeof(k_layout_names) / sizeof(k_layout_names[0])); ImVec4 abgr_to_imvec4(uint32_t c) { uint8_t r = (uint8_t)( c & 0xFF); uint8_t g = (uint8_t)((c >> 8) & 0xFF); uint8_t b = (uint8_t)((c >> 16) & 0xFF); uint8_t a = (uint8_t)((c >> 24) & 0xFF); return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, a / 255.0f); } void color_swatch(uint32_t color, float size = 12.0f) { ImVec2 p = ImGui::GetCursorScreenPos(); ImDrawList* dl = ImGui::GetWindowDrawList(); dl->AddRectFilled(p, ImVec2(p.x + size, p.y + size), ImGui::ColorConvertFloat4ToU32(abgr_to_imvec4(color)), 3.0f); ImGui::Dummy(ImVec2(size, size)); ImGui::SameLine(); } } // namespace void views_reset_visibility(AppState& app) { if (!app.graph) return; int nt = app.graph->type_count; int nr = app.graph->rel_type_count; if (nt > 256) nt = 256; if (nr > 256) nr = 256; for (int i = 0; i < nt; ++i) app.type_visible[i] = true; for (int i = 0; i < nr; ++i) app.rel_type_visible[i] = true; app.type_visible_n = nt; app.rel_type_visible_n = nr; } // FNV1a-64 — debe coincidir con graph_sources.cpp (escribe el hash de id en // node.user_data). Usado para mapear ids del FTS a nodos. static uint64_t fnv1a64_id(const char* s) { uint64_t h = 1469598103934665603ULL; for (; s && *s; ++s) { h ^= (uint8_t)*s; h *= 1099511628211ULL; } return h; } void views_apply_visibility(AppState& app) { // Toda la mascara (tipos + filter) se computa en views_filter_apply. // Mantenemos esta firma por compatibilidad; basta con marcar dirty y // dejar que el reapply integre todo de una pasada. app.filter_dirty = true; views_filter_apply(app); } bool views_filter_active(const AppState& app) { return app.filter_query_buf[0] != 0 || !app.filter_tags.empty(); } void views_filter_add_tag(AppState& app, const char* tag) { if (!tag || !*tag) return; for (const auto& t : app.filter_tags) if (t == tag) return; app.filter_tags.emplace_back(tag); app.filter_dirty = true; } void views_filter_clear(AppState& app) { app.filter_query_buf[0] = 0; app.filter_tags.clear(); app.filter_hits.clear(); app.filter_dropdown_open = false; app.filter_dirty = true; } void views_filter_apply(AppState& app) { if (!app.graph) { app.filter_dirty = false; return; } GraphData& g = *app.graph; const bool has_filter = views_filter_active(app); // 1) Construir el set de ids matching (FTS query AND tags). Si el filtro // esta inactivo, no hay set: todos los nodos son "match". std::unordered_set match_hashes; bool have_match_set = false; if (has_filter && !app.input_db_path.empty()) { std::vector ids_query; std::vector ids_tags; bool have_q = (app.filter_query_buf[0] != 0); bool have_t = !app.filter_tags.empty(); if (have_q) { std::vector hits; // Limit alto para que el filtro no recorte por encima del dropdown. entity_search_fts(app.input_db_path.c_str(), app.filter_query_buf, 200, &hits); ids_query.reserve(hits.size()); for (auto& h : hits) ids_query.emplace_back(std::move(h.id)); } if (have_t) { entity_list_by_tags(app.input_db_path.c_str(), app.filter_tags, &ids_tags); } std::vector* primary = nullptr; const std::vector* secondary = nullptr; if (have_q && have_t) { primary = &ids_query; secondary = &ids_tags; } else if (have_q) { primary = &ids_query; } else { primary = &ids_tags; } std::unordered_set sec_set; if (secondary) sec_set.insert(secondary->begin(), secondary->end()); match_hashes.reserve(primary->size()); for (const auto& id : *primary) { if (secondary && !sec_set.count(id)) continue; match_hashes.insert(fnv1a64_id(id.c_str())); } have_match_set = true; } // 2) Refrescar dropdown (max 20). Solo cuando hay query — los chips de // tags no contribuyen al dropdown (su feedback visual son los chips). if (app.filter_query_buf[0]) { app.filter_hits.clear(); if (!app.input_db_path.empty()) { entity_search_fts(app.input_db_path.c_str(), app.filter_query_buf, 20, &app.filter_hits); } } else { app.filter_hits.clear(); } // 3) Aplicar mascara a nodos: type-filter + filter (query/tags) + modo. const bool hide_mode = (app.filter_mode == AppState::FM_HIDE); for (int i = 0; i < g.node_count; ++i) { GraphNode& n = g.nodes[i]; uint16_t t = n.type_id; bool type_vis = (t < (uint16_t)app.type_visible_n) ? app.type_visible[t] : true; bool match = true; if (have_match_set) match = match_hashes.count(n.user_data) > 0; if (!type_vis) { n.flags &= ~NF_VISIBLE; n.color_override = 0u; continue; } if (!have_match_set || match) { n.flags |= NF_VISIBLE; n.color_override = 0u; // restaurar color del tipo } else if (hide_mode) { n.flags &= ~NF_VISIBLE; n.color_override = 0u; } else { // Highlight: dim al ~25% de alpha conservando RGB del tipo. uint32_t base = resolve_node_color(n, g.types, g.type_count); n.color_override = (base & 0x00FFFFFFu) | (0x40u << 24); n.flags |= NF_VISIBLE; } } // 4) Edges: visibles si ambos endpoints son visibles y el rel_type lo // permite. Mantiene el comportamiento previo. for (int i = 0; i < g.edge_count; ++i) { const GraphEdge& e = g.edges[i]; bool rel_vis = (e.type_id < (uint16_t)app.rel_type_visible_n) ? app.rel_type_visible[e.type_id] : true; bool src_vis = (e.source < (uint32_t)g.node_count) && (g.nodes[e.source].flags & NF_VISIBLE); bool tgt_vis = (e.target < (uint32_t)g.node_count) && (g.nodes[e.target].flags & NF_VISIBLE); bool vis = rel_vis && src_vis && tgt_vis; if (vis) g.edges[i].flags |= EF_VISIBLE; else g.edges[i].flags &= ~EF_VISIBLE; } app.filter_dirty = false; } // ---------------------------------------------------------------------------- // Toolbar // ---------------------------------------------------------------------------- void views_toolbar(AppState& app) { using namespace fn_ui; toolbar_begin(); // Project switcher — etiqueta = proyecto activo. Click abre popup con // New / Open / Recent / Reveal. Si no hay proyecto activo (modo // legacy con --input directo), muestra "(no project)". { char btn_label[128]; const char* p = app.active_project.empty() ? "(no project)" : app.active_project.c_str(); std::snprintf(btn_label, sizeof(btn_label), TI_FOLDER " Project: %s", p); if (button(btn_label, ButtonVariant::Secondary)) { // Refresca caches al abrir project_list(&app.project_list_cache); ProjectSettings ps; project_settings_load(&ps); app.project_recent_cache = ps.recent; ImGui::OpenPopup("##project_menu"); } if (ImGui::BeginPopup("##project_menu")) { if (ImGui::MenuItem(TI_PLUS " New project...")) { app.show_new_project_modal = true; app.new_project_buf[0] = 0; app.new_project_error.clear(); } ImGui::Separator(); // Recent submenu if (!app.project_recent_cache.empty()) { if (ImGui::BeginMenu("Recent")) { for (const auto& slug : app.project_recent_cache) { bool is_active = (slug == app.active_project); if (ImGui::MenuItem(slug.c_str(), nullptr, is_active) && !is_active) { app.want_switch_project = true; app.switch_project_target = slug; } } ImGui::EndMenu(); } } // Open submenu — todos los detectados if (ImGui::BeginMenu("Open")) { if (app.project_list_cache.empty()) { ImGui::TextDisabled("(no projects yet)"); } else { for (const auto& slug : app.project_list_cache) { bool is_active = (slug == app.active_project); if (ImGui::MenuItem(slug.c_str(), nullptr, is_active) && !is_active) { app.want_switch_project = true; app.switch_project_target = slug; } } } ImGui::EndMenu(); } ImGui::Separator(); bool can_reveal = !app.active_project.empty(); if (ImGui::MenuItem(TI_FOLDER_OPEN " Reveal in explorer", nullptr, false, can_reveal)) { project_reveal_in_explorer(app.active_project.c_str()); } ImGui::EndPopup(); } } ImGui::SameLine(); toolbar_separator(); if (button(TI_FOLDER " Open file...", ButtonVariant::Secondary)) { 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(); // Dropdown "Tables ▾" — toggle visibilidad de las ventanas Table // expandidas. Desmarcar = colapsar (cerrar ventana + expanded=false). { char btn[64]; std::snprintf(btn, sizeof(btn), TI_TABLE " Tables (%zu)", app.node_groups_windows.size()); if (button(btn, ButtonVariant::Subtle)) { ImGui::OpenPopup("##tables_menu"); } if (ImGui::BeginPopup("##tables_menu")) { if (app.node_groups_windows.empty()) { ImGui::TextDisabled("(no open NodeGroups)"); } else { for (auto& kv : app.node_groups_windows) { bool checked = kv.second.open; char lbl[160]; std::snprintf(lbl, sizeof(lbl), "%s (%lld rows)", kv.second.meta.name.c_str(), (long long)kv.second.total_rows); if (ImGui::MenuItem(lbl, nullptr, checked)) { kv.second.open = !checked; } } ImGui::Separator(); if (ImGui::MenuItem(TI_X " Collapse all")) { for (auto& kv : app.node_groups_windows) kv.second.open = false; } } ImGui::EndPopup(); } } ImGui::SameLine(); // Add node — input + auto-deteccion de tipo. Enter o boton "Add" lo // confirman; main.cpp inserta en operations.db y dispara reload. ImGui::SetNextItemWidth(220); DetectedType dt = detect_type(app.add_buf); ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue; char hint[64]; std::snprintf(hint, sizeof(hint), "Add node (%s)...", detected_type_name(dt)); if (ImGui::InputTextWithHint("##addnode", hint, app.add_buf, sizeof(app.add_buf), flags)) { app.want_add_node = true; } ImGui::SameLine(); if (button(TI_PLUS " Add", ButtonVariant::Primary)) { app.want_add_node = true; } ImGui::SameLine(); ImGui::TextDisabled("[%s]", detected_type_name(dt)); toolbar_separator(); // ---- Filtro / busqueda FTS5 (issue 0009) ----------------------- { ImGui::SetNextItemWidth(220); ImGuiInputTextFlags ff = ImGuiInputTextFlags_EnterReturnsTrue; char prev[128]; std::snprintf(prev, sizeof(prev), "%s", app.filter_query_buf); bool committed = ImGui::InputTextWithHint("##filterq", TI_SEARCH " Search name/desc/tags...", app.filter_query_buf, sizeof(app.filter_query_buf), ff); bool typing_changed = std::strcmp(prev, app.filter_query_buf) != 0; if (typing_changed || committed) { app.filter_dirty = true; app.filter_dropdown_open = (app.filter_query_buf[0] != 0); } if (ImGui::IsItemActivated()) { app.filter_dropdown_open = (app.filter_query_buf[0] != 0); } // Dropdown bajo el input mientras hay texto. if (app.filter_dropdown_open && !app.filter_hits.empty()) { ImVec2 anchor = ImGui::GetItemRectMin(); anchor.y = ImGui::GetItemRectMax().y + 2.0f; ImGui::SetNextWindowPos(anchor); ImGui::SetNextWindowSize(ImVec2(360.0f, 0.0f)); ImGui::SetNextWindowBgAlpha(0.97f); ImGuiWindowFlags wf = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoFocusOnAppearing; if (ImGui::Begin("##filter_dropdown", nullptr, wf)) { for (const auto& h : app.filter_hits) { char label[256]; std::snprintf(label, sizeof(label), "%s [%s]##h_%s", h.name.c_str(), h.type_ref.empty() ? "?" : h.type_ref.c_str(), h.id.c_str()); if (ImGui::Selectable(label)) { // Resolver a node_idx y pedir centrado. uint64_t hh = fnv1a64_id(h.id.c_str()); int idx = app.graph ? app.graph->find_node_by_user_data(hh) : -1; if (idx >= 0) { app.filter_focus_target = idx; if (app.viewport) { graph_viewport_clear_selection(*app.graph, *app.viewport); graph_viewport_add_to_selection(*app.graph, *app.viewport, idx); } } app.filter_dropdown_open = false; } } } ImGui::End(); } // Chips de tags activos. for (size_t i = 0; i < app.filter_tags.size();) { ImGui::SameLine(); char chip[96]; std::snprintf(chip, sizeof(chip), TI_TAG " %s " TI_X "##chip_%zu", app.filter_tags[i].c_str(), i); if (button(chip, ButtonVariant::Subtle)) { app.filter_tags.erase(app.filter_tags.begin() + i); app.filter_dirty = true; continue; } ++i; } ImGui::SameLine(); ImGui::SetNextItemWidth(120); ImGuiInputTextFlags tf = ImGuiInputTextFlags_EnterReturnsTrue; if (ImGui::InputTextWithHint("##filtertag", "+ tag", app.filter_tag_input, sizeof(app.filter_tag_input), tf)) { if (app.filter_tag_input[0]) { views_filter_add_tag(app, app.filter_tag_input); app.filter_tag_input[0] = 0; } } ImGui::SameLine(); ImGui::SetNextItemWidth(96); const char* mode_items[] = { "Highlight", "Hide" }; int m = app.filter_mode; if (ImGui::Combo("##fmode", &m, mode_items, 2)) { if (m != app.filter_mode) { app.filter_mode = m; app.filter_dirty = true; } } ImGui::SameLine(); bool can_clear = views_filter_active(app); if (!can_clear) ImGui::BeginDisabled(); if (button(TI_FILTER_OFF " Clear", ButtonVariant::Subtle)) { views_filter_clear(app); } if (!can_clear) ImGui::EndDisabled(); } toolbar_separator(); // Layout: ▾ — dropdown con todos los layouts + acciones. { char btn[96]; std::snprintf(btn, sizeof(btn), TI_LAYOUT_GRID " Layout: %s", k_layout_names[app.layout_mode % k_layout_count]); if (button(btn, ButtonVariant::Secondary)) { ImGui::OpenPopup("##layout_menu"); } if (ImGui::BeginPopup("##layout_menu")) { ImGui::TextDisabled("Apply layout"); ImGui::Separator(); for (int i = 0; i < k_layout_count; ++i) { bool is_cur = (i == app.layout_mode); if (ImGui::MenuItem(k_layout_names[i], nullptr, is_cur)) { app.layout_mode = i; ++app.apply_layout_tick; } } ImGui::Separator(); if (ImGui::MenuItem(TI_LAYOUT_GRID " Reset positions (unpin + restart)")) { app.want_unpin_all = true; ++app.apply_layout_tick; } if (ImGui::MenuItem(TI_DEVICE_FLOPPY " Save current layout")) { app.want_save_layout = true; } ImGui::EndPopup(); } } ImGui::SameLine(); // Physics ▶ / ⏸ — toggle visible. Solo afecta a layout 'force'. if (app.viewport) { const bool running = app.viewport->layout_running; const char* lbl = running ? TI_PLAYER_PAUSE " Physics: ON" : TI_PLAYER_PLAY " Physics: OFF"; ButtonVariant var = running ? ButtonVariant::Primary : ButtonVariant::Subtle; if (button(lbl, var)) { app.viewport->layout_running = !running; } } toolbar_separator(); if (button(TI_FILTER " Filters...", ButtonVariant::Subtle)) { app.show_filters_modal = true; } if (button(TI_ARROWS_MAXIMIZE " Fit view", ButtonVariant::Subtle)) { app.want_fit = true; } if (button(TI_REFRESH " Reload", ButtonVariant::Subtle)) { app.want_reload = true; } toolbar_separator(); ImGui::Checkbox("GPU layout", &app.use_gpu); ImGui::SameLine(); ImGui::Checkbox("Labels", &app.labels_enabled); toolbar_end(); } // ---------------------------------------------------------------------------- // Legend // ---------------------------------------------------------------------------- void views_legend(AppState& app) { if (!app.panel_legend) return; if (!ImGui::Begin("Legend", &app.panel_legend)) { ImGui::End(); return; } if (!app.graph) { ImGui::TextUnformatted("(no graph loaded)"); ImGui::End(); return; } GraphData& g = *app.graph; bool changed = false; ImGui::TextUnformatted("Entity types"); ImGui::Separator(); for (int i = 0; i < g.type_count && i < app.type_visible_n; ++i) { const EntityType& et = g.types[i]; color_swatch(et.color); char id[32]; std::snprintf(id, sizeof(id), "##t%d", i); bool v = app.type_visible[i]; if (ImGui::Checkbox(id, &v)) { app.type_visible[i] = v; changed = true; } ImGui::SameLine(); ImGui::TextUnformatted(et.name ? et.name : "(unnamed)"); } if (g.rel_type_count > 0) { ImGui::Spacing(); ImGui::TextUnformatted("Relation types"); ImGui::Separator(); for (int i = 0; i < g.rel_type_count && i < app.rel_type_visible_n; ++i) { const RelationType& rt = g.rel_types[i]; color_swatch(rt.color); char id[32]; std::snprintf(id, sizeof(id), "##r%d", i); bool v = app.rel_type_visible[i]; if (ImGui::Checkbox(id, &v)) { app.rel_type_visible[i] = v; changed = true; } ImGui::SameLine(); ImGui::TextUnformatted(rt.name ? rt.name : "(unnamed)"); } } if (changed) views_apply_visibility(app); ImGui::End(); } // ---------------------------------------------------------------------------- // Inspector helpers (issue 0008) // ---------------------------------------------------------------------------- namespace { const char* k_status_options[] = { "active", "stale", "corrupted", "archived" }; constexpr int k_status_count = 4; int status_to_idx(const std::string& s) { for (int i = 0; i < k_status_count; ++i) { if (s == k_status_options[i]) return i; } return 0; } const EntitySpec* find_entity_spec(const ParsedTypes& pt, const char* name) { if (!name || !*name) return nullptr; auto eq_ci = [&](const std::string& a, const char* b) { if (a.size() != std::strlen(b)) return false; for (size_t i = 0; i < a.size(); ++i) { if (std::tolower((unsigned char)a[i]) != std::tolower((unsigned char)b[i])) return false; } return true; }; for (const auto& e : pt.entities) { if (eq_ci(e.name, name)) return &e; } return nullptr; } // Asegura que el buffer de descripcion tiene al menos `need` bytes (con NUL). void ensure_desc_buf(std::vector& buf, size_t need) { if (need < 4096) need = 4096; if (buf.size() < need) buf.assign(need, 0); } void copy_to_buf(char* buf, size_t n, const std::string& s) { if (n == 0) return; size_t k = std::min(n - 1, s.size()); std::memcpy(buf, s.data(), k); buf[k] = 0; } } // namespace void views_inspector_clear_draft(AppState& app) { app.insp_node_idx = -1; app.insp_entity_id.clear(); app.insp_name_buf[0] = 0; app.insp_type_buf[0] = 0; app.insp_desc_buf.clear(); app.insp_status_idx = 0; app.insp_field_keys.clear(); app.insp_field_values.clear(); app.insp_is_extra.clear(); app.insp_tags.clear(); app.insp_tag_input[0] = 0; app.insp_extra_key[0] = 0; app.insp_dirty = false; app.insp_show_unsaved = false; app.insp_pending_target = -1; } void views_inspector_refresh_caches(AppState& app) { app.insp_tag_suggestions.clear(); if (!app.input_db_path.empty()) { entity_list_distinct_tags(app.input_db_path.c_str(), &app.insp_tag_suggestions); } app.insp_type_options.clear(); for (const auto& e : app.parsed_types.entities) { if (!e.name.empty()) app.insp_type_options.push_back(e.name); } // Si el grafo trae tipos no presentes en el yaml, anadirlos para que el // combo no pierda opciones. if (app.graph) { for (int i = 0; i < app.graph->type_count; ++i) { const char* nm = app.graph->types[i].name; if (!nm || !*nm) continue; bool dup = false; for (const auto& s : app.insp_type_options) { if (s == nm) { dup = true; break; } } if (!dup) app.insp_type_options.emplace_back(nm); } } } void views_inspector_load_draft(AppState& app, int node_idx, const char* entity_id) { views_inspector_clear_draft(app); if (!entity_id || !*entity_id) return; EntityRecord rec; if (!entity_load_full(app.input_db_path.c_str(), entity_id, &rec)) return; app.insp_node_idx = node_idx; app.insp_entity_id = entity_id; copy_to_buf(app.insp_name_buf, sizeof(app.insp_name_buf), rec.name); copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf), rec.type_ref); ensure_desc_buf(app.insp_desc_buf, rec.description.size() + 4096); std::memcpy(app.insp_desc_buf.data(), rec.description.data(), rec.description.size()); app.insp_status_idx = status_to_idx(rec.status); // Construye lista de fields: primero los del schema (en orden), luego // las extras (claves en metadata que no estan en el schema). const EntitySpec* spec = find_entity_spec(app.parsed_types, rec.type_ref.c_str()); auto find_meta = [&](const std::string& key) -> const MetadataField* { for (const auto& m : rec.metadata) if (m.key == key) return &m; return nullptr; }; if (spec) { for (const auto& f : spec->fields) { std::string val; if (auto m = find_meta(f.name)) val = m->value_str; app.insp_field_keys.push_back(f.name); app.insp_field_values.push_back(std::move(val)); app.insp_is_extra.push_back(0); } } // Extras: claves en metadata que no estan en el schema. for (const auto& m : rec.metadata) { bool in_schema = false; if (spec) { for (const auto& f : spec->fields) { if (f.name == m.key) { in_schema = true; break; } } } if (!in_schema) { app.insp_field_keys.push_back(m.key); app.insp_field_values.push_back(m.value_str); app.insp_is_extra.push_back(1); } } app.insp_tags = std::move(rec.tags); app.insp_dirty = false; } EntityRecord views_inspector_build_record(const AppState& app) { EntityRecord r; r.id = app.insp_entity_id; r.name = app.insp_name_buf; r.type_ref = app.insp_type_buf; r.description = app.insp_desc_buf.empty() ? std::string() : std::string(app.insp_desc_buf.data()); int sidx = app.insp_status_idx; if (sidx < 0 || sidx >= k_status_count) sidx = 0; r.status = k_status_options[sidx]; r.tags = app.insp_tags; const EntitySpec* spec = find_entity_spec(app.parsed_types, app.insp_type_buf); auto kind_for_key = [&](const std::string& k) -> FieldKind { if (!spec) return FK_STRING; for (const auto& f : spec->fields) if (f.name == k) return f.kind; return FK_STRING; }; for (size_t i = 0; i < app.insp_field_keys.size(); ++i) { const std::string& key = app.insp_field_keys[i]; const std::string& val = app.insp_field_values[i]; if (key.empty() || val.empty()) continue; bool is_extra = app.insp_is_extra[i] != 0; FieldKind kind = is_extra ? FK_STRING : kind_for_key(key); MetadataField mf; mf.key = key; mf.value_str = val; mf.is_string = (kind == FK_STRING || kind == FK_DATE || kind == FK_URL || kind == FK_ENUM); // Para int/float/bool, value_str debe ser literal valido. if (kind == FK_BOOL) { std::string lv = val; std::transform(lv.begin(), lv.end(), lv.begin(), [](unsigned char c){ return std::tolower(c); }); mf.value_str = (lv == "true" || lv == "1" || lv == "yes") ? "true" : "false"; } r.metadata.push_back(std::move(mf)); } return r; } // ---------------------------------------------------------------------------- // Inspector — render del panel editable // ---------------------------------------------------------------------------- void views_inspector(AppState& app) { if (!app.panel_inspector) return; if (!ImGui::Begin("Inspector", &app.panel_inspector)) { ImGui::End(); return; } if (!app.graph || !app.viewport) { ImGui::TextUnformatted("(no graph)"); ImGui::End(); return; } GraphData& g = *app.graph; const auto& sel = app.viewport->selection; if (sel.empty()) { if (app.insp_node_idx != -1 && !app.insp_dirty) { views_inspector_clear_draft(app); } ImGui::TextUnformatted("No selection."); ImGui::TextWrapped("Click a node, or shift+drag to lasso a region."); if (app.insp_dirty) { ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f)); ImGui::TextWrapped("Cambios sin guardar en %s", app.insp_entity_id.c_str()); ImGui::PopStyleColor(); if (fn_ui::button("Save", fn_ui::ButtonVariant::Primary)) { app.want_inspector_save = true; } ImGui::SameLine(); if (fn_ui::button("Discard", fn_ui::ButtonVariant::Subtle)) { app.want_inspector_discard = true; } } ImGui::End(); return; } if (sel.size() > 1) { if (app.insp_node_idx != -1 && !app.insp_dirty) { views_inspector_clear_draft(app); } ImGui::Text("%zu nodes selected", sel.size()); ImGui::TextDisabled("(edicion multi-seleccion no soportada)"); ImGui::Separator(); for (size_t i = 0; i < sel.size() && i < 32; ++i) { int idx = sel[i]; if (idx < 0 || idx >= g.node_count) continue; const GraphNode& n = g.nodes[idx]; const char* lbl = graph::graph_label(&g, n.label_idx); ImGui::BulletText("[%d] %s", idx, lbl && *lbl ? lbl : "(unnamed)"); } if (sel.size() > 32) ImGui::TextDisabled("(...%zu more)", sel.size() - 32); ImGui::End(); return; } int idx = sel.front(); if (idx < 0 || idx >= g.node_count) { ImGui::TextUnformatted("(invalid selection)"); ImGui::End(); return; } // Sincroniza draft con seleccion actual. Si hay cambios pendientes y la // seleccion cambio, mostramos un banner pidiendo Save/Discard antes de // cargar el nodo nuevo. Mientras tanto el draft sigue siendo el del // nodo anterior. if (app.insp_node_idx != idx) { if (app.insp_dirty && app.insp_node_idx >= 0 && app.insp_node_idx < g.node_count) { // No cargar — esperar decision del usuario. ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f)); ImGui::TextWrapped("Cambios sin guardar (nodo cambio en el viewport)." " Save / Discard primero."); ImGui::PopStyleColor(); ImGui::Separator(); } else { // Resolver el sql id desde el viewport callback no esta aqui, // asi que main.cpp debe haber rellenado insp_pending_target + // input_db_path; pero la ruta normal es: main.cpp detecta cambio // de seleccion y llama views_inspector_load_draft. Aqui solo // limpiamos si el user_data no resuelve. // Simplemente intentamos cargar via insp_pending_target. } } // Si el draft no esta cargado para este idx, mostramos un placeholder. // main.cpp es responsable de llamar load_draft cuando seleccion cambia // y no hay dirty. if (app.insp_node_idx != idx) { ImGui::TextDisabled("Cargando nodo %d...", idx); ImGui::End(); return; } const GraphNode& n = g.nodes[idx]; ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::Text("id=%s user_data=%llx pos=(%.1f, %.1f)", app.insp_entity_id.c_str(), (unsigned long long)n.user_data, n.x, n.y); ImGui::PopStyleColor(); ImGui::Separator(); bool any_change = false; // ---- Identidad ---- // Layout label-izquierda / input-derecha via 2-col table. El label // alineado al frame del input y el input estirado al ancho restante. ImGui::TextUnformatted("Identity"); ImGui::Separator(); if (ImGui::BeginTable("##insp_id", 2, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_NoBordersInBody)) { ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90.0f); ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch); // name ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("name"); ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-FLT_MIN); if (ImGui::InputText("##name", app.insp_name_buf, sizeof(app.insp_name_buf))) any_change = true; // type combo ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("type"); ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-FLT_MIN); { int cur = -1; for (size_t i = 0; i < app.insp_type_options.size(); ++i) { if (app.insp_type_options[i] == app.insp_type_buf) { cur = (int)i; break; } } if (ImGui::BeginCombo("##type", app.insp_type_buf)) { for (size_t i = 0; i < app.insp_type_options.size(); ++i) { bool is_sel = (int)i == cur; if (ImGui::Selectable(app.insp_type_options[i].c_str(), is_sel)) { copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf), app.insp_type_options[i]); any_change = true; } if (is_sel) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } } // status combo ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("status"); ImGui::TableNextColumn(); ImGui::SetNextItemWidth(-FLT_MIN); if (ImGui::Combo("##status", &app.insp_status_idx, k_status_options, k_status_count)) any_change = true; ImGui::EndTable(); } // description — multiline va debajo de su label, ocupando todo el // ancho. Con 60 px de alto entra ~3 lineas; el usuario hace scroll // dentro del input para textos mas largos. ImGui::Spacing(); ImGui::TextUnformatted("description"); if (app.insp_desc_buf.empty()) ensure_desc_buf(app.insp_desc_buf, 4096); if (ImGui::InputTextMultiline("##desc", app.insp_desc_buf.data(), app.insp_desc_buf.size(), ImVec2(-FLT_MIN, 60.0f))) any_change = true; // ---- Schema fields + Extras ---- // Misma idea que Identity: 2-col table con label izquierda, input // derecha. Para extras añadimos un boton trash inline; para URLs un // boton Open. Ambos son SmallButton tras un input mas estrecho. if (!app.insp_field_keys.empty()) { ImGui::Spacing(); ImGui::TextUnformatted("Fields"); ImGui::Separator(); const EntitySpec* spec = find_entity_spec(app.parsed_types, app.insp_type_buf); if (ImGui::BeginTable("##insp_fields", 2, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_NoBordersInBody)) { ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90.0f); ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch); for (size_t i = 0; i < app.insp_field_keys.size(); ++i) { const std::string& key = app.insp_field_keys[i]; std::string& val = app.insp_field_values[i]; bool is_extra = app.insp_is_extra[i] != 0; ImGui::PushID((int)i); // Encuentra la FieldSpec si es del schema. const FieldSpec* fs = nullptr; if (!is_extra && spec) { for (const auto& f : spec->fields) { if (f.name == key) { fs = &f; break; } } } FieldKind kind = fs ? fs->kind : FK_STRING; // Label izquierdo. Marca `*` si es required, prefijo // [extra] si es campo libre añadido por el usuario. ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); if (is_extra) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.65f, 0.65f, 0.50f, 1.0f)); ImGui::Text("%s", key.c_str()); ImGui::PopStyleColor(); } else if (fs && fs->required) { ImGui::Text("%s *", key.c_str()); } else { ImGui::TextUnformatted(key.c_str()); } // Input derecha. Reserva espacio para el trailing button // cuando aplique (URL Open, extras trash). ImGui::TableNextColumn(); bool needs_trail_btn = is_extra || (kind == FK_URL && !val.empty() && (val.rfind("http://", 0) == 0 || val.rfind("https://", 0) == 0)); ImGui::SetNextItemWidth(needs_trail_btn ? -32.0f : -FLT_MIN); char buf[1024]; size_t k = std::min(sizeof(buf) - 1, val.size()); std::memcpy(buf, val.data(), k); buf[k] = 0; bool changed = false; switch (kind) { case FK_BOOL: { bool b = (val == "true" || val == "1"); if (ImGui::Checkbox("##v", &b)) { val = b ? "true" : "false"; changed = true; } break; } case FK_INT: { int n = std::atoi(val.c_str()); if (ImGui::InputInt("##v", &n)) { char nb[32]; std::snprintf(nb, sizeof(nb), "%d", n); val = nb; changed = true; } break; } case FK_FLOAT: { double d = std::atof(val.c_str()); if (ImGui::InputDouble("##v", &d, 0.0, 0.0, "%.6g")) { char nb[64]; std::snprintf(nb, sizeof(nb), "%.10g", d); val = nb; changed = true; } break; } case FK_ENUM: { if (fs && !fs->enum_values.empty()) { int cur = -1; for (size_t e = 0; e < fs->enum_values.size(); ++e) { if (fs->enum_values[e] == val) { cur = (int)e; break; } } if (ImGui::BeginCombo("##v", val.c_str())) { for (size_t e = 0; e < fs->enum_values.size(); ++e) { bool is_sel = (int)e == cur; if (ImGui::Selectable(fs->enum_values[e].c_str(), is_sel)) { val = fs->enum_values[e]; changed = true; } if (is_sel) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } } else { if (ImGui::InputText("##v", buf, sizeof(buf))) { val = buf; changed = true; } } break; } case FK_URL: if (ImGui::InputText("##v", buf, sizeof(buf))) { val = buf; changed = true; } if (!val.empty() && (val.rfind("http://", 0) == 0 || val.rfind("https://", 0) == 0)) { ImGui::SameLine(); if (ImGui::SmallButton(TI_EXTERNAL_LINK "##url")) { #if defined(_WIN32) std::string cmd = "start \"\" \"" + val + "\""; #else std::string cmd = "xdg-open '" + val + "' >/dev/null 2>&1 &"; #endif int rc = std::system(cmd.c_str()); (void)rc; } } break; case FK_DATE: case FK_STRING: default: if (ImGui::InputTextWithHint("##v", kind == FK_DATE ? "YYYY-MM-DD" : "", buf, sizeof(buf))) { val = buf; changed = true; } break; } if (is_extra) { ImGui::SameLine(); if (ImGui::SmallButton(TI_TRASH "##rm")) { app.insp_field_keys.erase(app.insp_field_keys.begin() + i); app.insp_field_values.erase(app.insp_field_values.begin() + i); app.insp_is_extra.erase(app.insp_is_extra.begin() + i); ImGui::PopID(); any_change = true; --i; continue; } } if (changed) any_change = true; ImGui::PopID(); } ImGui::EndTable(); } } // ---- Add extra ---- ImGui::Spacing(); ImGui::TextUnformatted("Extra field"); ImGui::Separator(); ImGui::SetNextItemWidth(160); ImGui::InputTextWithHint("##xkey", "key", app.insp_extra_key, sizeof(app.insp_extra_key)); ImGui::SameLine(); if (fn_ui::button(TI_PLUS " Add", fn_ui::ButtonVariant::Subtle)) { if (app.insp_extra_key[0]) { // Evitar colision con keys ya presentes std::string k = app.insp_extra_key; bool dup = false; for (const auto& x : app.insp_field_keys) { if (x == k) { dup = true; break; } } if (!dup) { app.insp_field_keys.push_back(k); app.insp_field_values.emplace_back(""); app.insp_is_extra.push_back(1); any_change = true; } app.insp_extra_key[0] = 0; } } // ---- Tags ---- ImGui::Spacing(); ImGui::TextUnformatted("Tags"); ImGui::Separator(); for (size_t i = 0; i < app.insp_tags.size(); ++i) { ImGui::PushID((int)i); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.30f, 0.50f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.40f, 0.65f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.35f, 0.50f, 0.75f, 1.0f)); std::string lbl = app.insp_tags[i] + " x"; bool removed = false; if (ImGui::SmallButton(lbl.c_str())) { app.insp_tags.erase(app.insp_tags.begin() + i); removed = true; any_change = true; } else if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { // Right-click anade el tag como chip del filtro (issue 0009). views_filter_add_tag(app, app.insp_tags[i].c_str()); } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("click: quitar | right-click: filtrar por tag"); } ImGui::PopStyleColor(3); ImGui::PopID(); if (removed) { --i; continue; } if ((i + 1) % 4 != 0) ImGui::SameLine(); } if (!app.insp_tags.empty()) ImGui::NewLine(); ImGui::SetNextItemWidth(160); ImGuiInputTextFlags tflags = ImGuiInputTextFlags_EnterReturnsTrue; bool commit_tag = ImGui::InputTextWithHint("##taginput", "add tag, Enter", app.insp_tag_input, sizeof(app.insp_tag_input), tflags); ImGui::SameLine(); if (fn_ui::button("Add tag", fn_ui::ButtonVariant::Subtle)) commit_tag = true; if (commit_tag && app.insp_tag_input[0]) { std::string t = app.insp_tag_input; bool dup = false; for (const auto& x : app.insp_tags) if (x == t) { dup = true; break; } if (!dup) { app.insp_tags.push_back(t); any_change = true; } app.insp_tag_input[0] = 0; } if (!app.insp_tag_suggestions.empty()) { ImGui::TextDisabled("(in db: %zu tags distintas)", app.insp_tag_suggestions.size()); // Lista compacta clickable de las primeras 12 sugerencias. int shown = 0; for (const auto& s : app.insp_tag_suggestions) { if (shown >= 12) { ImGui::TextDisabled("..."); break; } // Ocultar las ya presentes bool already = false; for (const auto& x : app.insp_tags) if (x == s) { already = true; break; } if (already) continue; ImGui::SmallButton(s.c_str()); if (ImGui::IsItemClicked()) { app.insp_tags.push_back(s); any_change = true; } ImGui::SameLine(); ++shown; } if (shown > 0) ImGui::NewLine(); } // ---- Footer: Save / Discard / Notes / Neighbors ---- ImGui::Spacing(); ImGui::Separator(); if (any_change) app.insp_dirty = true; if (app.insp_dirty) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f)); ImGui::TextUnformatted("(modified)"); ImGui::PopStyleColor(); } if (fn_ui::button(TI_DEVICE_FLOPPY " Save", app.insp_dirty ? fn_ui::ButtonVariant::Primary : fn_ui::ButtonVariant::Subtle)) { app.want_inspector_save = true; } ImGui::SameLine(); if (fn_ui::button("Discard", fn_ui::ButtonVariant::Subtle)) { app.want_inspector_discard = true; } ImGui::SameLine(); if (fn_ui::button(TI_FILE_TEXT " Open notes", fn_ui::ButtonVariant::Subtle)) { app.want_open_note = true; app.open_note_target = idx; } // ---- Neighbors (read-only) ---- ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::TextUnformatted("Neighbors:"); ImGui::PopStyleColor(); int neighbor_count = 0; for (int e = 0; e < g.edge_count && neighbor_count < 64; ++e) { const GraphEdge& edge = g.edges[e]; int other = -1; const char* arrow = " "; if (edge.source == (uint32_t)idx) { other = (int)edge.target; arrow = "->"; } else if (edge.target == (uint32_t)idx) { other = (int)edge.source; arrow = "<-"; } if (other < 0 || other >= g.node_count) continue; const char* olbl = graph::graph_label(&g, g.nodes[other].label_idx); const char* rname = (edge.type_id < (uint16_t)g.rel_type_count && g.rel_types[edge.type_id].name) ? g.rel_types[edge.type_id].name : k_default_relation_name; char buf[256]; std::snprintf(buf, sizeof(buf), "%s %s [%d] %s", arrow, rname, other, olbl && *olbl ? olbl : "(unnamed)"); if (ImGui::Selectable(buf)) { graph_viewport_clear_selection(g, *app.viewport); graph_viewport_add_to_selection(g, *app.viewport, other); } ++neighbor_count; } if (neighbor_count == 0) ImGui::TextDisabled("(none)"); ImGui::End(); } // ---------------------------------------------------------------------------- // Stats // ---------------------------------------------------------------------------- void views_stats(AppState& app) { if (!app.panel_stats) return; if (!ImGui::Begin("Stats", &app.panel_stats)) { ImGui::End(); return; } if (!app.graph || !app.viewport) { ImGui::TextUnformatted("(no graph)"); ImGui::End(); return; } int sel = (int)app.viewport->selection.size(); ImGui::Text("nodes=%d edges=%d types=%d rel_types=%d", app.graph->node_count, app.graph->edge_count, app.graph->type_count, app.graph->rel_type_count); ImGui::Text("fps=%d energy=%.4f selection=%d", app.fps_estimate, app.viewport->layout_energy, sel); ImGui::Text("layout=%s mode=%s", k_layout_names[app.layout_mode % k_layout_count], app.use_gpu ? "GPU" : "CPU"); ImGui::End(); } // ---------------------------------------------------------------------------- // Note editor (markdown) // ---------------------------------------------------------------------------- void views_note(AppState& app) { if (!app.panel_note) return; if (!ImGui::Begin(TI_FILE_TEXT " Note", &app.panel_note, ImGuiWindowFlags_MenuBar)) { ImGui::End(); return; } if (ImGui::BeginMenuBar()) { if (ImGui::MenuItem(TI_DEVICE_FLOPPY " Save", "Ctrl+S", /*selected=*/false, app.note_dirty)) { app.want_save_note = true; } if (app.note_dirty) { ImGui::TextDisabled("(modified)"); } ImGui::EndMenuBar(); } if (app.note_node < 0) { ImGui::TextDisabled("Doble click sobre un nodo para abrir su nota."); ImGui::End(); return; } ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::TextUnformatted("entity:"); ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextUnformatted(app.note_entity_label.empty() ? "(unnamed)" : app.note_entity_label.c_str()); ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::TextUnformatted("type:"); ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextUnformatted(app.note_entity_type.empty() ? "(no-type)" : app.note_entity_type.c_str()); ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::TextUnformatted("id:"); ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextUnformatted(app.note_entity_id.c_str()); ImGui::Separator(); if (app.note_buf.empty()) app.note_buf.resize(1024, 0); // Crece el buffer si esta cerca del limite. if (std::strlen(app.note_buf.data()) + 64 >= app.note_buf.size()) { app.note_buf.resize(app.note_buf.size() * 2, 0); } ImVec2 size = ImGui::GetContentRegionAvail(); if (ImGui::InputTextMultiline("##note_md", app.note_buf.data(), app.note_buf.size(), size, ImGuiInputTextFlags_AllowTabInput)) { app.note_dirty = true; } if (ImGui::IsItemHovered() && ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_S)) { app.want_save_note = true; } ImGui::End(); } // ---------------------------------------------------------------------------- // Modals // ---------------------------------------------------------------------------- bool views_filters_modal(AppState& app) { if (!app.show_filters_modal) return false; bool changed = false; if (fn_ui::modal_dialog_begin("Filters", &app.show_filters_modal, ImVec2(520, 0))) { if (!app.graph) { ImGui::TextUnformatted("(no graph)"); } else { ImGui::TextUnformatted("Entity types"); ImGui::Separator(); int nt = app.type_visible_n; ImGui::Columns(2, "##fent", false); for (int i = 0; i < nt; ++i) { color_swatch(app.graph->types[i].color); char id[32]; std::snprintf(id, sizeof(id), "##fe%d", i); bool v = app.type_visible[i]; if (ImGui::Checkbox(id, &v)) { app.type_visible[i] = v; changed = true; } ImGui::SameLine(); ImGui::TextUnformatted(app.graph->types[i].name ? app.graph->types[i].name : "?"); ImGui::NextColumn(); } ImGui::Columns(1); if (app.graph->rel_type_count > 0) { ImGui::Spacing(); ImGui::TextUnformatted("Relation types"); ImGui::Separator(); int nr = app.rel_type_visible_n; ImGui::Columns(2, "##frel", false); for (int i = 0; i < nr; ++i) { color_swatch(app.graph->rel_types[i].color); char id[32]; std::snprintf(id, sizeof(id), "##fr%d", i); bool v = app.rel_type_visible[i]; if (ImGui::Checkbox(id, &v)) { app.rel_type_visible[i] = v; changed = true; } ImGui::SameLine(); ImGui::TextUnformatted(app.graph->rel_types[i].name ? app.graph->rel_types[i].name : "?"); ImGui::NextColumn(); } ImGui::Columns(1); } ImGui::Spacing(); if (fn_ui::button("Show all", fn_ui::ButtonVariant::Subtle)) { for (int i = 0; i < app.type_visible_n; ++i) app.type_visible[i] = true; for (int i = 0; i < app.rel_type_visible_n; ++i) app.rel_type_visible[i] = true; changed = true; } ImGui::SameLine(); if (fn_ui::button("Hide all", fn_ui::ButtonVariant::Subtle)) { for (int i = 0; i < app.type_visible_n; ++i) app.type_visible[i] = false; for (int i = 0; i < app.rel_type_visible_n; ++i) app.rel_type_visible[i] = false; changed = true; } ImGui::SameLine(); if (fn_ui::button("Close", fn_ui::ButtonVariant::Primary)) { app.show_filters_modal = false; } } } fn_ui::modal_dialog_end(); if (changed) views_apply_visibility(app); return changed; } // ---------------------------------------------------------------------------- // Modal: New project // ---------------------------------------------------------------------------- bool views_new_project_modal(AppState& app) { if (!app.show_new_project_modal) return false; bool created = false; if (fn_ui::modal_dialog_begin("New project", &app.show_new_project_modal, ImVec2(480, 0))) { ImGui::TextWrapped( "Crea una subcarpeta en projects/ con su propio operations.db," " types.yaml y graph_explorer.db."); ImGui::Spacing(); fn_ui::text_input("Slug", app.new_project_buf, sizeof(app.new_project_buf), "caso_aurgi"); ImGui::TextDisabled("a-z, 0-9, '_' y '-' (max 64)"); if (!app.new_project_error.empty()) { ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.5f, 0.4f, 1.0f)); ImGui::TextWrapped("%s", app.new_project_error.c_str()); ImGui::PopStyleColor(); } ImGui::Spacing(); if (fn_ui::button("Create", fn_ui::ButtonVariant::Primary)) { std::string err; if (!project_validate_slug(app.new_project_buf, &err)) { app.new_project_error = err; } else if (project_exists(app.new_project_buf)) { app.new_project_error = "ya existe un proyecto con ese slug"; } else if (!project_create(app.new_project_buf, &err)) { app.new_project_error = err; } else { // Switch al recien creado app.want_switch_project = true; app.switch_project_target = app.new_project_buf; app.show_new_project_modal = false; app.new_project_error.clear(); created = true; } } ImGui::SameLine(); if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) { app.show_new_project_modal = false; app.new_project_error.clear(); } } fn_ui::modal_dialog_end(); return created; } bool views_open_modal(AppState& app) { if (!app.show_open_modal) return false; bool opened = false; if (fn_ui::modal_dialog_begin("Open file", &app.show_open_modal, ImVec2(520, 0))) { ImGui::TextWrapped("Path to operations.db (or any supported source)."); ImGui::Spacing(); fn_ui::text_input("Path", app.open_buf, sizeof(app.open_buf), "apps/registry_dashboard/operations.db"); ImGui::Spacing(); if (fn_ui::button("Open", fn_ui::ButtonVariant::Primary)) { if (app.open_buf[0]) { app.want_open_file = true; app.show_open_modal = false; opened = true; } } ImGui::SameLine(); if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) { app.show_open_modal = false; } } fn_ui::modal_dialog_end(); return opened; } // ---------------------------------------------------------------------------- // Table view (issue 0004) // ---------------------------------------------------------------------------- void views_table_refresh_indices(AppState& app) { if (!app.graph) return; GraphData& g = *app.graph; // Degree map: user_data -> count. std::unordered_map deg; deg.reserve((size_t)g.node_count * 2); for (int i = 0; i < g.edge_count; ++i) { const GraphEdge& e = g.edges[i]; if (e.source < (uint32_t)g.node_count) deg[g.nodes[e.source].user_data]++; if (e.target < (uint32_t)g.node_count) deg[g.nodes[e.target].user_data]++; } for (auto& r : app.table_rows) { uint64_t h = fnv1a64_id(r.id.c_str()); r.node_idx = g.find_node_by_user_data(h); auto it = deg.find(h); r.neighbors = (it == deg.end()) ? 0 : it->second; } } namespace { // Comparador estable para ImGuiTableSortSpecs. struct TableSortCtx { const ImGuiTableSortSpecs* specs; }; TableSortCtx g_table_sort_ctx; bool table_row_lt(const AppState::TableRow& a, const AppState::TableRow& b) { const ImGuiTableSortSpecs* specs = g_table_sort_ctx.specs; if (!specs) return a.name < b.name; for (int n = 0; n < specs->SpecsCount; ++n) { const ImGuiTableColumnSortSpecs& s = specs->Specs[n]; int delta = 0; switch (s.ColumnUserID) { case 0: delta = a.id.compare(b.id); break; case 1: delta = a.name.compare(b.name); break; case 2: delta = a.type_ref.compare(b.type_ref); break; case 3: delta = a.status.compare(b.status); break; case 4: delta = a.updated_at.compare(b.updated_at); break; case 5: delta = (a.neighbors < b.neighbors) ? -1 : (a.neighbors > b.neighbors) ? 1 : 0; break; default: break; } if (delta != 0) { return (s.SortDirection == ImGuiSortDirection_Ascending) ? (delta < 0) : (delta > 0); } } return false; } bool ci_contains(const std::string& hay, const char* needle) { if (!needle || !*needle) return true; auto lower = [](char c){ return (char)std::tolower((unsigned char)c); }; std::string h; h.reserve(hay.size()); for (char c : hay) h.push_back(lower(c)); std::string n; for (const char* p = needle; *p; ++p) n.push_back(lower(*p)); return h.find(n) != std::string::npos; } // Mapeo column_user_id -> nombre legible y string-getter sobre TableRow. struct TableColMeta { int user_id; const char* name; }; const TableColMeta k_table_cols[] = { {0, "id"}, {1, "name"}, {2, "type"}, {3, "status"}, {4, "updated_at"}, {5, "neighbors"}, }; constexpr int k_table_col_n = (int)(sizeof(k_table_cols) / sizeof(k_table_cols[0])); const std::string& table_row_field(const AppState::TableRow& r, int user_id) { static const std::string empty_str; static thread_local std::string scratch; switch (user_id) { case 0: return r.id; case 1: return r.name; case 2: return r.type_ref; case 3: return r.status; case 4: return r.updated_at; case 5: scratch = std::to_string(r.neighbors); return scratch; } return empty_str; } const char* table_col_name_by_id(int user_id) { for (int i = 0; i < k_table_col_n; ++i) if (k_table_cols[i].user_id == user_id) return k_table_cols[i].name; return "?"; } // Render header row con popup right-click por columna para anadir filtro. void render_table_headers_with_filters(AppState& app) { ImGui::TableNextRow(ImGuiTableRowFlags_Headers); for (int i = 0; i < k_table_col_n; ++i) { ImGui::TableSetColumnIndex(i); const char* name = ImGui::TableGetColumnName(i); ImGui::PushID(i); ImGui::TableHeader(name); if (ImGui::BeginPopupContextItem("##colfilt", ImGuiPopupFlags_MouseButtonRight)) { int user_id = k_table_cols[i].user_id; ImGui::TextDisabled("Filter %s", k_table_cols[i].name); ImGui::Separator(); // Si reabrimos el popup para esta columna, sembrar el buffer. if (app.table_filter_pending_col != user_id) { app.table_filter_pending_col = user_id; auto it = app.table_col_filters.find(user_id); std::snprintf(app.table_filter_input, sizeof(app.table_filter_input), "%s", it == app.table_col_filters.end() ? "" : it->second.c_str()); ImGui::SetKeyboardFocusHere(); } ImGui::SetNextItemWidth(220); ImGuiInputTextFlags fflags = ImGuiInputTextFlags_EnterReturnsTrue; bool commit = ImGui::InputTextWithHint("##filt_in", "substring (case-insensitive)", app.table_filter_input, sizeof(app.table_filter_input), fflags); ImGui::SameLine(); if (ImGui::SmallButton("Apply") || commit) { if (app.table_filter_input[0]) { app.table_col_filters[user_id] = app.table_filter_input; } else { app.table_col_filters.erase(user_id); } app.table_filter_pending_col = -1; ImGui::CloseCurrentPopup(); } ImGui::SameLine(); if (ImGui::SmallButton("Clear")) { app.table_col_filters.erase(user_id); app.table_filter_input[0] = 0; app.table_filter_pending_col = -1; ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } else if (app.table_filter_pending_col == k_table_cols[i].user_id) { // popup se cerro sin aplicar — limpiar pending. app.table_filter_pending_col = -1; } ImGui::PopID(); } } void render_one_table(AppState& app, std::vector& visible_indices) { ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | ImGuiTableFlags_Sortable | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp; if (!ImGui::BeginTable("##tablev", 6, flags)) return; ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_DefaultSort, 0, 0); ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_None, 0, 1); ImGui::TableSetupColumn("type", ImGuiTableColumnFlags_None, 0, 2); ImGui::TableSetupColumn("status", ImGuiTableColumnFlags_None, 0, 3); ImGui::TableSetupColumn("updated_at", ImGuiTableColumnFlags_None, 0, 4); ImGui::TableSetupColumn("neighbors", ImGuiTableColumnFlags_WidthFixed, 80.0f, 5); render_table_headers_with_filters(app); ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs(); if (specs && specs->SpecsDirty) { g_table_sort_ctx.specs = specs; std::sort(visible_indices.begin(), visible_indices.end(), [&app](int a, int b) { return table_row_lt(app.table_rows[a], app.table_rows[b]); }); specs->SpecsDirty = false; } ImGuiListClipper clipper; clipper.Begin((int)visible_indices.size()); while (clipper.Step()) { for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) { int ri = visible_indices[row]; const auto& r = app.table_rows[ri]; ImGui::TableNextRow(); ImGui::PushID(ri); ImGui::TableSetColumnIndex(0); char sel_lbl[256]; std::snprintf(sel_lbl, sizeof(sel_lbl), "%s##sel", r.id.c_str()); bool is_sel = (app.viewport && r.node_idx >= 0 && graph_viewport_is_selected(*app.viewport, r.node_idx)); if (ImGui::Selectable(sel_lbl, is_sel, ImGuiSelectableFlags_SpanAllColumns)) { if (r.node_idx >= 0 && app.graph && app.viewport) { graph_viewport_clear_selection(*app.graph, *app.viewport); graph_viewport_add_to_selection(*app.graph, *app.viewport, r.node_idx); app.filter_focus_target = r.node_idx; } } ImGui::TableSetColumnIndex(1); ImGui::TextUnformatted(r.name.c_str()); ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(r.type_ref.c_str()); ImGui::TableSetColumnIndex(3); ImGui::TextUnformatted(r.status.c_str()); ImGui::TableSetColumnIndex(4); ImGui::TextUnformatted(r.updated_at.c_str()); ImGui::TableSetColumnIndex(5); ImGui::Text("%d", r.neighbors); ImGui::PopID(); } } ImGui::EndTable(); } } // namespace void views_table(AppState& app) { if (!app.panel_table) return; if (!ImGui::Begin("Table", &app.panel_table)) { ImGui::End(); return; } // Toolbar superior: search + show all. ImGui::SetNextItemWidth(220); ImGui::InputTextWithHint("##tsearch", TI_SEARCH " filter name/id...", app.table_search_buf, sizeof(app.table_search_buf)); ImGui::SameLine(); ImGui::Checkbox("Show all types", &app.table_show_all); ImGui::SameLine(); ImGui::TextDisabled("%zu rows", app.table_rows.size()); // Chips de filtros activos por columna (right-click sobre header lo anade). if (!app.table_col_filters.empty()) { ImGui::TextDisabled("Filters:"); ImGui::SameLine(); int del_col = -1; for (auto& kv : app.table_col_filters) { ImGui::SameLine(); char chip[160]; std::snprintf(chip, sizeof(chip), TI_FILTER " %s: %s " TI_X "##chip_%d", table_col_name_by_id(kv.first), kv.second.c_str(), kv.first); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.30f, 0.50f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.40f, 0.65f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.35f, 0.50f, 0.75f, 1.0f)); if (ImGui::SmallButton(chip)) del_col = kv.first; ImGui::PopStyleColor(3); } if (del_col >= 0) app.table_col_filters.erase(del_col); ImGui::SameLine(); if (fn_ui::button("Clear all", fn_ui::ButtonVariant::Subtle)) { app.table_col_filters.clear(); } } if (app.table_rows.empty()) { ImGui::TextDisabled("(no entities loaded)"); ImGui::End(); return; } // Indices por tipo presentes en el snapshot. std::vector types_present; types_present.reserve(8); { std::unordered_set seen; for (const auto& r : app.table_rows) { if (seen.insert(r.type_ref).second) types_present.push_back(r.type_ref); } std::sort(types_present.begin(), types_present.end()); } auto build_visible = [&](const char* type_filter) { std::vector v; v.reserve(app.table_rows.size()); for (size_t i = 0; i < app.table_rows.size(); ++i) { const auto& r = app.table_rows[i]; if (type_filter && r.type_ref != type_filter) continue; if (app.table_search_buf[0] && !ci_contains(r.name, app.table_search_buf) && !ci_contains(r.id, app.table_search_buf)) continue; // Filtros por columna (AND de todos). bool reject = false; for (auto& kv : app.table_col_filters) { const std::string& field = table_row_field(r, kv.first); if (!ci_contains(field, kv.second.c_str())) { reject = true; break; } } if (reject) continue; v.push_back((int)i); } return v; }; if (app.table_show_all) { auto visible = build_visible(nullptr); ImGui::TextDisabled("All types — %zu visible", visible.size()); render_one_table(app, visible); } else if (ImGui::BeginTabBar("##ttabs")) { for (size_t i = 0; i < types_present.size(); ++i) { const std::string& t = types_present[i]; char lbl[96]; std::snprintf(lbl, sizeof(lbl), "%s##tt%zu", t.empty() ? "(none)" : t.c_str(), i); if (ImGui::BeginTabItem(lbl)) { app.table_active_tab = (int)i; auto visible = build_visible(t.c_str()); ImGui::TextDisabled("%zu rows visible", visible.size()); render_one_table(app, visible); ImGui::EndTabItem(); } } ImGui::EndTabBar(); } ImGui::End(); } // ---------------------------------------------------------------------------- // Table node UI fase 2 (issue 0011) — ventana expandida + import // ---------------------------------------------------------------------------- AppState::NodeGroupsWindowState* views_node_groups_open(AppState& app, const std::string& container_id, NodeGroupsKind kind, const char* ops_db) { if (container_id.empty()) return nullptr; auto it = app.node_groups_windows.find(container_id); if (it != app.node_groups_windows.end()) { // Ya existe — no recargar metadata, solo pedir focus. El kind se // respeta tal como estaba (mover entre kinds para el mismo id no // tiene sentido en la UI actual). it->second.open = true; it->second.focus_request = true; return &it->second; } auto& w = app.node_groups_windows[container_id]; w.kind = kind; w.open = true; w.focus_request = true; w.page_dirty = true; w.offset = 0; w.page.clear(); w.total_rows = 0; w.last_error.clear(); // Pre-popular meta segun el kind. Para kind=Group, las columnas son // fijas y conocidas — no hace falta tocar BD para descubrirlas, y // tampoco hay un nodo type='Table' que leer. w.meta = NodeGroupsMeta{}; w.meta.entity_id = container_id; if (kind == NodeGroupsKind::Group) { w.meta.columns = {"id", "name", "type_ref", "status", "updated_at"}; w.meta.id_column = "id"; w.meta.label_column = "name"; // Best effort: leer el name del Group desde operations.db para que // el titulo de la ventana sea informativo. Si falla no bloquea. if (ops_db && *ops_db) { sqlite3* db = nullptr; if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) == SQLITE_OK) { sqlite3_stmt* st = nullptr; if (sqlite3_prepare_v2(db, "SELECT name FROM entities WHERE id = ?", -1, &st, nullptr) == SQLITE_OK) { sqlite3_bind_text(st, 1, container_id.c_str(), -1, SQLITE_TRANSIENT); if (sqlite3_step(st) == SQLITE_ROW) { const unsigned char* p = sqlite3_column_text(st, 0); if (p) w.meta.name = (const char*)p; } sqlite3_finalize(st); } sqlite3_close(db); } } } else { // kind=Table: cargar metadata real del nodo Table-typed. El path // tipico para entries creadas por views_node_groups_windows_sync // ya hace esto, pero si llaman a views_node_groups_open directo // queremos comportamiento equivalente. if (ops_db && *ops_db) { NodeGroupsMeta meta; if (node_groups_get_metadata(ops_db, container_id.c_str(), &meta)) { w.meta = std::move(meta); } } } return &w; } void views_node_groups_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; } // json_extract devuelve INTEGER 1 para JSON true; comparamos contra 1 // (json('true') no es comparable directo — devuelve TEXT 'true'). const char* sql = "SELECT id FROM entities " "WHERE type_ref = 'Table' AND json_extract(metadata,'$.expanded') = 1"; 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; NodeGroupsMeta meta; if (node_groups_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 — pero solo las kind=Table. // Las kind=Group viven en operations.db con su propia condicion de // existencia (entity con type_ref='Group') y no deben tocarse aqui. for (auto it = app.node_groups_windows.begin(); it != app.node_groups_windows.end(); ) { if (it->second.kind == NodeGroupsKind::Group) { ++it; continue; } if (live.find(it->first) == live.end()) it = app.node_groups_windows.erase(it); else ++it; } // Anadir las nuevas o refrescar metadata. Tras cualquier sync forzamos // page_dirty = true para que la siguiente iteracion del render relea // la pagina contra DuckDB (se evita asi mostrar pages obsoletas tras // promote/demote/import — donde el flag promoted de cada fila puede // haber cambiado). for (auto& kv : live) { auto& w = app.node_groups_windows[kv.first]; bool was_present = !w.meta.entity_id.empty(); w.kind = NodeGroupsKind::Table; // expanded -> siempre Table w.meta = std::move(kv.second); w.open = true; w.page_dirty = true; if (!was_present) { w.offset = 0; w.page.clear(); w.total_rows = 0; } } } void views_node_groups_window(AppState& app) { if (app.node_groups_windows.empty()) return; GraphData* g = app.graph; GraphViewportState* vp = app.viewport; for (auto& kv : app.node_groups_windows) { NodeGroupsMeta& m = kv.second.meta; AppState::NodeGroupsWindowState& w = kv.second; const bool is_group = (w.kind == NodeGroupsKind::Group); char title[160]; if (is_group) { std::snprintf(title, sizeof(title), TI_TABLE " Group: %s##te_%s", m.name.empty() ? "(unnamed)" : m.name.c_str(), m.entity_id.c_str()); } else { std::snprintf(title, sizeof(title), TI_TABLE " NodeGroups: %s##te_%s", m.name.empty() ? "(unnamed)" : m.name.c_str(), m.entity_id.c_str()); } ImGui::SetNextWindowSize(ImVec2(640, 460), ImGuiCond_FirstUseEver); const bool focus_now = w.focus_request; if (focus_now) { // 0036c: forzar foco antes de Begin para que la window quede al // frente al abrirse o reabrirse desde doble click. ImGui::SetNextWindowFocus(); w.focus_request = false; } if (!ImGui::Begin(title, &w.open)) { ImGui::End(); continue; } if (focus_now) { // Doble seguridad: tambien lo llamamos dentro del Begin/End por // si la window estaba ya abierta y ImGui ignora SetNextWindowFocus // en ese caso (raro, pero barato). ImGui::SetWindowFocus(); } // Header de info (varia por kind) if (is_group) { ImGui::TextDisabled("group_id=%s · %lld rows", m.entity_id.c_str(), (long long)w.total_rows); } else { ImGui::TextDisabled("%s · %s · %lld rows", m.duckdb_path.c_str(), m.table_name.c_str(), (long long)w.total_rows); } // Issue 0036d: tooltip suave que explica el promote segun kind. if (is_group) { ImGui::TextDisabled("Promote: saca el nodo del grupo"); } else { ImGui::TextDisabled( "Promote: convierte fila DuckDB en entidad del grafo"); } if (!w.last_error.empty()) { ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "ERROR: %s", w.last_error.c_str()); } ImGui::Separator(); // Tabla — layout depende del kind: // Table: [id_column] + columns[] + [promoted] (col_count = N+2) // Group: columns[] + [promote] (col_count = N+1) // (issue 0036d: ultima columna lleva un boton TI_ARROW_UP // para sacar la entidad del grupo.) const int col_count = is_group ? (int)m.columns.size() + 1 : (int)m.columns.size() + 2; ImGuiTableFlags tflags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchProp; if (col_count > 0 && ImGui::BeginTable("##te_rows", col_count, tflags, ImVec2(0, -ImGui::GetFrameHeightWithSpacing()))) { ImGui::TableSetupScrollFreeze(0, 1); if (is_group) { for (size_t i = 0; i < m.columns.size(); ++i) { bool is_id = (i == 0); ImGui::TableSetupColumn(m.columns[i].c_str(), is_id ? ImGuiTableColumnFlags_WidthFixed : ImGuiTableColumnFlags_WidthStretch, is_id ? 160.0f : 0.0f); } // 0036d: columna extra para el boton Promote-out-of-group. ImGui::TableSetupColumn("promote", ImGuiTableColumnFlags_WidthFixed, 60.0f); } else { 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(); for (int64_t i = 0; i < (int64_t)w.page.size(); ++i) { const NodeGroupsRow& row = w.page[i]; ImGui::TableNextRow(); ImGui::PushID((int)(w.offset + i)); ImGui::TableSetColumnIndex(0); // Selectable spanning para que el doble-click y el right-click // funcionen sobre toda la fila, no solo el texto. ImGuiSelectableFlags sf = ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick; ImGui::Selectable(row.id.c_str(), false, sf); if (is_group) { // 0036e: en kind=Group la fila YA es una entidad real del // grafo. Single click → focus + select en viewport. // Doble click tambien dispara focus (mismo efecto). // Right click → menu contextual con focus. if (ImGui::IsItemHovered() && (ImGui::IsMouseClicked(0) || ImGui::IsMouseDoubleClicked(0))) { app.want_focus_entity = true; app.focus_entity_id = row.id; } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Click to focus entity in viewport"); } if (ImGui::BeginPopupContextItem()) { if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) { app.want_focus_entity = true; app.focus_entity_id = row.id; } ImGui::EndPopup(); } // Render de las columnas (la 0 ya tiene el Selectable; // el texto del id se ve en el propio Selectable). for (size_t c = 1; c < m.columns.size(); ++c) { ImGui::TableSetColumnIndex((int)c); if (c < row.values.size()) ImGui::TextUnformatted(row.values[c].c_str()); } // 0036d: boton Promote-out-of-group en la ultima columna. ImGui::TableSetColumnIndex(col_count - 1); if (ImGui::SmallButton(TI_ARROW_UP "##promote_grp")) { app.want_clear_group_id_entity = true; app.clear_group_id_entity_id = row.id; } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Promote out of group (move to canvas)"); } } else { // kind=Table (DuckDB-backed). // 0036e: click ramificado por estado de promocion: // - promovida → single click = focus en viewport. // - no promovida → single click = no-op + hint tooltip. // El doble click sobre fila no promovida sigue lanzando // el flujo de promote (legado de 0036c) por convenience. bool is_promoted = !row.promoted_entity_id.empty(); if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) { if (is_promoted) { app.want_focus_entity = true; app.focus_entity_id = row.promoted_entity_id; } // else: no-op (hint mostrado via tooltip abajo). } 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::IsItemHovered()) { if (is_promoted) { ImGui::SetTooltip("Click to focus entity in viewport"); } else { ImGui::SetTooltip("promote first to focus\n(double click or right click to promote)"); } } if (ImGui::BeginPopupContextItem()) { 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 // node_groups_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) // ---------------------------------------------------------------------------- void views_node_groups_overlay(AppState& app) { if (!app.graph || !app.viewport) return; GraphData& g = *app.graph; if (g.type_count == 0) return; const ImVec2 wmin = ImGui::GetItemRectMin(); const ImVec2 wmax = ImGui::GetItemRectMax(); const float cx = (wmin.x + wmax.x) * 0.5f; const float cy = (wmin.y + wmax.y) * 0.5f; ImDrawList* dl = ImGui::GetWindowDrawList(); if (!dl) return; ImFont* font = ImGui::GetFont(); // El cuadrado lo pinta el GPU (apply_types_yaml fija shape=SQUARE + // size=32 para tipos Table). Aqui solo añadimos un contador discreto // BAJO el cuadrado: "1000 rows". for (int i = 0; i < g.node_count; ++i) { const GraphNode& n = g.nodes[i]; if (!(n.flags & NF_VISIBLE)) continue; 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; const float zoom = app.viewport->zoom; const float vx = (n.x - app.viewport->cam_x) * zoom + cx; const float vy = (n.y - app.viewport->cam_y) * zoom + cy; if (vx < wmin.x - 100 || vx > wmax.x + 100) continue; if (vy < wmin.y - 100 || vy > wmax.y + 100) continue; int64_t count = -1; auto it = app.node_groups_counts.find(n.user_data); if (it != app.node_groups_counts.end()) count = it->second; if (count < 0) continue; char buf[64]; std::snprintf(buf, sizeof(buf), "%lld rows", (long long)count); const float font_size = 12.0f; if (!font) continue; ImVec2 ts = font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, buf); // Posicion: bajo el cuadrado. La mitad del shape en pixeles depende // del default_size del tipo y del zoom. const float half_h = (t.default_size * zoom) * 0.5f; const float gap = 4.0f; const float tx = vx - ts.x * 0.5f; const float ty = vy + half_h + gap; // Pequeño bg semitransparente para que el texto sea legible sobre // grafos densos, sin parecer un chip. dl->AddRectFilled(ImVec2(tx - 4, ty - 1), ImVec2(tx + ts.x + 4, ty + ts.y + 1), IM_COL32(20, 25, 35, 180), 3.0f); dl->AddText(font, font_size, ImVec2(tx, ty), IM_COL32(200, 220, 240, 230), buf); } } // ---------------------------------------------------------------------------- // Type Editor (issue 0007) // ---------------------------------------------------------------------------- namespace { const char* k_shape_names[] = { "(use type)", "circle", "square", "diamond", "hex", "triangle", "rounded_square", }; constexpr int k_shape_count = (int)(sizeof(k_shape_names) / sizeof(k_shape_names[0])); const char* k_style_names[] = { "(use type)", "solid", "dashed", "dotted", }; constexpr int k_style_count = (int)(sizeof(k_style_names) / sizeof(k_style_names[0])); const char* k_field_kind_names[] = { "string", "int", "float", "bool", "date", "url", "enum", }; constexpr int k_field_kind_count = (int)(sizeof(k_field_kind_names) / sizeof(k_field_kind_names[0])); ImVec4 abgr_to_imvec4_full(uint32_t c) { return ImVec4( (float)( c & 0xFF) / 255.0f, (float)((c >> 8) & 0xFF) / 255.0f, (float)((c >> 16) & 0xFF) / 255.0f, (float)((c >> 24) & 0xFF) / 255.0f); } uint32_t imvec4_to_abgr(const ImVec4& v) { auto clamp01 = [](float x) { return x < 0 ? 0.f : (x > 1 ? 1.f : x); }; uint8_t r = (uint8_t)(clamp01(v.x) * 255.0f + 0.5f); uint8_t g = (uint8_t)(clamp01(v.y) * 255.0f + 0.5f); uint8_t b = (uint8_t)(clamp01(v.z) * 255.0f + 0.5f); uint8_t a = (uint8_t)(clamp01(v.w) * 255.0f + 0.5f); return (uint32_t)r | ((uint32_t)g << 8) | ((uint32_t)b << 16) | ((uint32_t)a << 24); } } // namespace void views_type_editor(AppState& app) { if (!app.panel_type_editor) return; if (!ImGui::Begin("Types", &app.panel_type_editor)) { ImGui::End(); return; } if (ImGui::BeginTabBar("##te_tabs")) { // ---- Entities tab -------------------------------------------------- if (ImGui::BeginTabItem("Entities")) { app.te_tab_idx = 0; ImGui::BeginChild("##te_left", ImVec2(160, 0), true); for (int i = 0; i < (int)app.types_draft.entities.size(); ++i) { ImGui::PushID(i); bool sel = (i == app.te_entity_idx); const auto& e = app.types_draft.entities[i]; if (ImGui::Selectable(e.name.empty() ? "(unnamed)" : e.name.c_str(), sel)) { app.te_entity_idx = i; } ImGui::PopID(); } ImGui::EndChild(); ImGui::SameLine(); ImGui::BeginGroup(); // +/- buttons if (fn_ui::button(TI_PLUS " Add entity type", fn_ui::ButtonVariant::Subtle)) { EntitySpec ne; ne.name = "NewType"; app.types_draft.entities.push_back(std::move(ne)); app.te_entity_idx = (int)app.types_draft.entities.size() - 1; app.types_dirty = true; } ImGui::SameLine(); bool can_del = (app.te_entity_idx >= 0 && app.te_entity_idx < (int)app.types_draft.entities.size()); if (!can_del) ImGui::BeginDisabled(); if (fn_ui::button(TI_X " Delete", fn_ui::ButtonVariant::Subtle)) { app.te_pending_delete_e = app.te_entity_idx; app.te_pending_delete_r = -1; app.show_te_delete_modal = true; } if (!can_del) ImGui::EndDisabled(); ImGui::Separator(); if (can_del) { EntitySpec& e = app.types_draft.entities[app.te_entity_idx]; // Name char namebuf[80]; std::snprintf(namebuf, sizeof(namebuf), "%s", e.name.c_str()); if (ImGui::InputText("Name", namebuf, sizeof(namebuf))) { e.name = namebuf; app.types_dirty = true; } // Color ImVec4 col = abgr_to_imvec4_full(e.color ? e.color : 0xFF888888u); if (ImGui::ColorEdit4("Color", (float*)&col, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_AlphaBar)) { e.color = imvec4_to_abgr(col); app.types_dirty = true; } // Shape int sh_idx = (e.shape <= SHAPE_ROUNDED_SQUARE) ? (int)e.shape : 0; if (ImGui::Combo("Shape", &sh_idx, k_shape_names, k_shape_count)) { e.shape = (uint8_t)sh_idx; app.types_dirty = true; } // Icon char ibuf[64]; std::snprintf(ibuf, sizeof(ibuf), "%s", e.icon_name.c_str()); if (ImGui::InputText("Icon (ti-*)", ibuf, sizeof(ibuf))) { e.icon_name = ibuf; e.icon_cp = tabler_codepoint_by_name(ibuf); app.types_dirty = true; } ImGui::SameLine(); ImGui::TextDisabled("cp=0x%04X", e.icon_cp); // Principal field (combo entre fields existentes; permite "name" por default) std::vector pf_opts; pf_opts.push_back("(name)"); for (const auto& fs : e.fields) pf_opts.push_back(fs.name.c_str()); int pf_idx = 0; for (int i = 1; i < (int)pf_opts.size(); ++i) { if (e.principal_field == pf_opts[i]) { pf_idx = i; break; } } if (e.principal_field.empty()) pf_idx = 0; if (ImGui::Combo("Principal field", &pf_idx, pf_opts.data(), (int)pf_opts.size())) { e.principal_field = (pf_idx == 0) ? std::string() : std::string(pf_opts[pf_idx]); app.types_dirty = true; } // Fields table ImGui::Spacing(); ImGui::TextUnformatted("Fields"); ImGui::Separator(); if (ImGui::BeginTable("##te_fields", 5, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("name"); ImGui::TableSetupColumn("type", ImGuiTableColumnFlags_WidthFixed, 90.0f); ImGui::TableSetupColumn("required", ImGuiTableColumnFlags_WidthFixed, 70.0f); ImGui::TableSetupColumn("values", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 60.0f); ImGui::TableHeadersRow(); int del_idx = -1; int up_idx = -1; int dn_idx = -1; for (int fi = 0; fi < (int)e.fields.size(); ++fi) { FieldSpec& fs = e.fields[fi]; ImGui::PushID(fi); ImGui::TableNextRow(); // name ImGui::TableSetColumnIndex(0); ImGui::SetNextItemWidth(-FLT_MIN); char fbuf[64]; std::snprintf(fbuf, sizeof(fbuf), "%s", fs.name.c_str()); if (ImGui::InputText("##fname", fbuf, sizeof(fbuf))) { fs.name = fbuf; app.types_dirty = true; } // type ImGui::TableSetColumnIndex(1); ImGui::SetNextItemWidth(-FLT_MIN); int kidx = (int)fs.kind; if (kidx < 0 || kidx >= k_field_kind_count) kidx = 0; if (ImGui::Combo("##ftype", &kidx, k_field_kind_names, k_field_kind_count)) { fs.kind = (FieldKind)kidx; if (fs.kind != FK_ENUM) fs.enum_values.clear(); app.types_dirty = true; } // required ImGui::TableSetColumnIndex(2); if (ImGui::Checkbox("##freq", &fs.required)) app.types_dirty = true; // values (CSV editable solo si enum) ImGui::TableSetColumnIndex(3); if (fs.kind == FK_ENUM) { std::string csv; for (size_t i = 0; i < fs.enum_values.size(); ++i) { if (i) csv += ", "; csv += fs.enum_values[i]; } char vbuf[256]; std::snprintf(vbuf, sizeof(vbuf), "%s", csv.c_str()); ImGui::SetNextItemWidth(-FLT_MIN); if (ImGui::InputText("##fval", vbuf, sizeof(vbuf))) { fs.enum_values.clear(); std::string s = vbuf; size_t start = 0; while (start < s.size()) { size_t end = s.find(',', start); if (end == std::string::npos) end = s.size(); std::string tok = s.substr(start, end - start); while (!tok.empty() && std::isspace((unsigned char)tok.front())) tok.erase(tok.begin()); while (!tok.empty() && std::isspace((unsigned char)tok.back())) tok.pop_back(); if (!tok.empty()) fs.enum_values.push_back(std::move(tok)); start = end + 1; } app.types_dirty = true; } } else { ImGui::TextDisabled("-"); } // controls ImGui::TableSetColumnIndex(4); if (ImGui::SmallButton(TI_X "##fd")) del_idx = fi; ImGui::SameLine(); if (fi > 0 && ImGui::SmallButton("^##fu")) up_idx = fi; ImGui::SameLine(); if (fi + 1 < (int)e.fields.size() && ImGui::SmallButton("v##fdn")) dn_idx = fi; ImGui::PopID(); } ImGui::EndTable(); if (del_idx >= 0) { e.fields.erase(e.fields.begin() + del_idx); app.types_dirty = true; } else if (up_idx > 0) { std::swap(e.fields[up_idx - 1], e.fields[up_idx]); app.types_dirty = true; } else if (dn_idx >= 0 && dn_idx + 1 < (int)e.fields.size()) { std::swap(e.fields[dn_idx], e.fields[dn_idx + 1]); app.types_dirty = true; } } if (fn_ui::button(TI_PLUS " Add field", fn_ui::ButtonVariant::Subtle)) { FieldSpec ns; ns.name = "field" + std::to_string(e.fields.size() + 1); e.fields.push_back(std::move(ns)); app.types_dirty = true; } } else { ImGui::TextDisabled("(no entity selected)"); } ImGui::EndGroup(); ImGui::EndTabItem(); } // ---- Relations tab ------------------------------------------------- if (ImGui::BeginTabItem("Relations")) { app.te_tab_idx = 1; ImGui::BeginChild("##te_left_r", ImVec2(160, 0), true); for (int i = 0; i < (int)app.types_draft.relations.size(); ++i) { ImGui::PushID(i + 10000); bool sel = (i == app.te_relation_idx); const auto& r = app.types_draft.relations[i]; if (ImGui::Selectable(r.name.empty() ? "(unnamed)" : r.name.c_str(), sel)) { app.te_relation_idx = i; } ImGui::PopID(); } ImGui::EndChild(); ImGui::SameLine(); ImGui::BeginGroup(); if (fn_ui::button(TI_PLUS " Add relation type", fn_ui::ButtonVariant::Subtle)) { RelationSpec nr; nr.name = "new_relation"; app.types_draft.relations.push_back(std::move(nr)); app.te_relation_idx = (int)app.types_draft.relations.size() - 1; app.types_dirty = true; } ImGui::SameLine(); bool can_del_r = (app.te_relation_idx >= 0 && app.te_relation_idx < (int)app.types_draft.relations.size()); if (!can_del_r) ImGui::BeginDisabled(); if (fn_ui::button(TI_X " Delete", fn_ui::ButtonVariant::Subtle)) { app.te_pending_delete_r = app.te_relation_idx; app.te_pending_delete_e = -1; app.show_te_delete_modal = true; } if (!can_del_r) ImGui::EndDisabled(); ImGui::Separator(); if (can_del_r) { RelationSpec& r = app.types_draft.relations[app.te_relation_idx]; char rnbuf[80]; std::snprintf(rnbuf, sizeof(rnbuf), "%s", r.name.c_str()); if (ImGui::InputText("Name", rnbuf, sizeof(rnbuf))) { r.name = rnbuf; app.types_dirty = true; } ImVec4 rcol = abgr_to_imvec4_full(r.color ? r.color : 0xFF888888u); if (ImGui::ColorEdit4("Color", (float*)&rcol, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_AlphaBar)) { r.color = imvec4_to_abgr(rcol); app.types_dirty = true; } int st_idx = (r.style <= EDGE_DOTTED) ? (int)r.style : 0; if (ImGui::Combo("Style", &st_idx, k_style_names, k_style_count)) { r.style = (uint8_t)st_idx; app.types_dirty = true; } } else { ImGui::TextDisabled("(no relation selected)"); } ImGui::EndGroup(); ImGui::EndTabItem(); } ImGui::EndTabBar(); } // ---- Footer (Save / Reload) ------------------------------------------- ImGui::Separator(); if (!app.types_dirty) ImGui::BeginDisabled(); if (fn_ui::button(TI_DEVICE_FLOPPY " Save to types.yaml", fn_ui::ButtonVariant::Primary)) { app.want_types_save = true; } if (!app.types_dirty) ImGui::EndDisabled(); ImGui::SameLine(); if (fn_ui::button(TI_REFRESH " Reload from disk", fn_ui::ButtonVariant::Subtle)) { app.want_types_reload = true; } ImGui::SameLine(); if (app.types_dirty) { ImGui::TextColored(ImVec4(0.95f, 0.6f, 0.2f, 1.0f), "* unsaved changes"); } else { ImGui::TextDisabled("clean"); } if (!app.types_save_error.empty()) { ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "%s", app.types_save_error.c_str()); } ImGui::End(); } bool views_type_editor_delete_modal(AppState& app) { if (!app.show_te_delete_modal) return false; bool confirmed = false; if (fn_ui::modal_dialog_begin("Delete type", &app.show_te_delete_modal, ImVec2(440, 0))) { const char* tname = "?"; const char* tkind = "?"; if (app.te_pending_delete_e >= 0 && app.te_pending_delete_e < (int)app.types_draft.entities.size()) { tname = app.types_draft.entities[app.te_pending_delete_e].name.c_str(); tkind = "entity"; } else if (app.te_pending_delete_r >= 0 && app.te_pending_delete_r < (int)app.types_draft.relations.size()) { tname = app.types_draft.relations[app.te_pending_delete_r].name.c_str(); tkind = "relation"; } ImGui::Text("Eliminar %s type \"%s\"?", tkind, tname); if (app.te_delete_use_count > 0) { ImGui::TextColored(ImVec4(0.95f, 0.6f, 0.2f, 1.0f), "Hay %d entidades en uso de este type_ref. Quedaran huerfanas hasta que las cambies.", app.te_delete_use_count); } else { ImGui::TextDisabled("Ninguna entidad usa este tipo actualmente."); } ImGui::Spacing(); if (fn_ui::button("Delete", fn_ui::ButtonVariant::Primary)) { if (app.te_pending_delete_e >= 0 && app.te_pending_delete_e < (int)app.types_draft.entities.size()) { app.types_draft.entities.erase( app.types_draft.entities.begin() + app.te_pending_delete_e); if (app.te_entity_idx >= (int)app.types_draft.entities.size()) app.te_entity_idx = (int)app.types_draft.entities.size() - 1; } else if (app.te_pending_delete_r >= 0 && app.te_pending_delete_r < (int)app.types_draft.relations.size()) { app.types_draft.relations.erase( app.types_draft.relations.begin() + app.te_pending_delete_r); if (app.te_relation_idx >= (int)app.types_draft.relations.size()) app.te_relation_idx = (int)app.types_draft.relations.size() - 1; } app.te_pending_delete_e = -1; app.te_pending_delete_r = -1; app.te_delete_use_count = 0; app.types_dirty = true; app.show_te_delete_modal = false; confirmed = true; } ImGui::SameLine(); if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) { app.te_pending_delete_e = -1; app.te_pending_delete_r = -1; app.te_delete_use_count = 0; app.show_te_delete_modal = false; } } fn_ui::modal_dialog_end(); return confirmed; } } // namespace ge