Files
graph_explorer/views.cpp
T
egutierrez 8f91b4ed23 feat(0036c): doble click en Group abre NodeGroups; cleanup Table panel
Cambia el dispatch del doble click sobre nodos del viewport: si el tipo
es Group o Table, ahora abre/enfoca la NodeGroups window correspondiente
via views_node_groups_open(...). El branch de Group ya no carga el
panel Table generico con un filtro group_id (logica heredada de 0035d
que provocaba el bug de "tabla vacia").

Limpieza correlativa en views_table:
  - Eliminado el breadcrumb "Group: <name> (N)" + boton Clear filter.
  - Eliminado el filtro r.group_id != table_filter_group_id en
    build_visible y la restriccion de types_present.
  - Eliminado el reset on-close de los campos de filtro.

Eliminados los campos AppState::table_filter_group_id y
table_filter_group_name (audit: git grep table_filter_group_id devuelve
vacio fuera de issues/).

Render de NodeGroups ahora consume focus_request: llama
SetNextWindowFocus() antes de Begin y SetWindowFocus() dentro, asi la
window queda al frente tanto al crearse como al re-enfocarse.

El right-click "Open NodeGroups" del context menu sigue intacto
(want_toggle_nodegroups + node_groups_set_expanded). El doble click es
flujo paralelo nuevo.

Refs: issues/0036c-double-click-group-opens-nodegroups.md
2026-05-04 00:56:44 +02:00

2709 lines
110 KiB
C++

#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 <algorithm>
#include <cctype>
#include <cfloat>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unordered_set>
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<uint64_t> match_hashes;
bool have_match_set = false;
if (has_filter && !app.input_db_path.empty()) {
std::vector<std::string> ids_query;
std::vector<std::string> ids_tags;
bool have_q = (app.filter_query_buf[0] != 0);
bool have_t = !app.filter_tags.empty();
if (have_q) {
std::vector<EntityHit> 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<std::string>* primary = nullptr;
const std::vector<std::string>* 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<std::string> 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: <name> ▾ — 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<char>& 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<uint64_t, int> 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<int>& 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<std::string> types_present;
types_present.reserve(8);
{
std::unordered_set<std::string> 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<int> 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<std::string, NodeGroupsMeta> 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);
}
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[] (id, name, type_ref, status, updated_at)
// (col_count = N)
const int col_count = is_group
? (int)m.columns.size()
: (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);
}
} 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) {
// En kind=Group la fila YA es una entidad real del grafo.
// Doble click → focus inspector. Right click → focus.
// (El boton "Promote" no aplica — 0036d hace eso para
// contextos donde tenga sentido.)
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
app.want_focus_entity = true;
app.focus_entity_id = row.id;
}
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());
}
} else {
// kind=Table — comportamiento original (DuckDB-backed).
bool is_promoted = !row.promoted_entity_id.empty();
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
if (is_promoted) {
app.want_focus_entity = true;
app.focus_entity_id = row.promoted_entity_id;
} else {
app.want_promote_row = true;
app.promote_table_id = m.entity_id;
app.promote_row_id = row.id;
}
}
if (ImGui::BeginPopupContextItem()) {
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<int64_t>(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<const char*> 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