#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 "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(); // 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(); ImGui::TextUnformatted("Layout:"); ImGui::SameLine(); ImGui::SetNextItemWidth(140); int idx = app.layout_mode; if (ImGui::Combo("##layout", &idx, k_layout_names, k_layout_count)) { if (idx != app.layout_mode) { app.layout_mode = idx; ++app.apply_layout_tick; } } 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_DEVICE_FLOPPY " Save layout", ButtonVariant::Subtle)) { app.want_save_layout = true; } if (button(TI_REFRESH " Reload", ButtonVariant::Subtle)) { app.want_reload = true; } if (button(TI_LAYOUT_GRID " Reset layout", ButtonVariant::Subtle)) { app.want_unpin_all = true; ++app.apply_layout_tick; } toolbar_separator(); ImGui::Checkbox("GPU layout", &app.use_gpu); ImGui::SameLine(); ImGui::Checkbox("Labels", &app.labels_enabled); if (app.viewport) { ImGui::SameLine(); ImGui::Checkbox("Run layout", &app.viewport->layout_running); } 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 ---- ImGui::TextUnformatted("Identity"); ImGui::Separator(); if (ImGui::InputText("name", app.insp_name_buf, sizeof(app.insp_name_buf))) any_change = true; // type combo { 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; } } // Si el tipo no esta en el cache (raro), mostrar como tal y permitir // introducirlo via input. Combo simple aqui. 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 if (ImGui::Combo("status", &app.insp_status_idx, k_status_options, k_status_count)) any_change = true; // description multiline if (app.insp_desc_buf.empty()) ensure_desc_buf(app.insp_desc_buf, 4096); if (ImGui::InputTextMultiline("description", app.insp_desc_buf.data(), app.insp_desc_buf.size(), ImVec2(-FLT_MIN, 60.0f))) any_change = true; // ---- Schema fields + Extras ---- 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); 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; std::string label = key; if (fs && fs->required) label += " *"; if (is_extra) label = "[extra] " + key; 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(label.c_str(), &b)) { val = b ? "true" : "false"; changed = true; } break; } case FK_INT: { int n = std::atoi(val.c_str()); if (ImGui::InputInt(label.c_str(), &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(label.c_str(), &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(label.c_str(), 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 { // Sin valores: tratar como string if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) { val = buf; changed = true; } } break; } case FK_URL: if (ImGui::InputText(label.c_str(), 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("Open##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(label.c_str(), 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(); } } // ---- 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; } } // namespace ge