diff --git a/views.cpp b/views.cpp index 4e85ee2..59a4948 100644 --- a/views.cpp +++ b/views.cpp @@ -17,7 +17,11 @@ #include "imgui.h" +#include +#include +#include #include +#include #include namespace ge { @@ -283,7 +287,191 @@ void views_legend(AppState& app) { } // ---------------------------------------------------------------------------- -// Inspector +// 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) { @@ -302,14 +490,35 @@ void views_inspector(AppState& app) { 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]; @@ -329,28 +538,335 @@ void views_inspector(AppState& app) { 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]; - const char* lbl = graph::graph_label(&g, n.label_idx); - const char* tname = (n.type_id < (uint16_t)g.type_count && g.types[n.type_id].name) - ? g.types[n.type_id].name : "(no-type)"; - ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); - ImGui::TextUnformatted("label:"); + 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(); - ImGui::TextUnformatted(lbl && *lbl ? lbl : "(none)"); + 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; + } + } - ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); - ImGui::TextUnformatted("type:"); - ImGui::PopStyleColor(); + // ---- 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"; + if (ImGui::SmallButton(lbl.c_str())) { + app.insp_tags.erase(app.insp_tags.begin() + i); + ImGui::PopStyleColor(3); + ImGui::PopID(); + any_change = true; + --i; + continue; + } + ImGui::PopStyleColor(3); + ImGui::PopID(); + 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(); - ImGui::TextUnformatted(tname); + 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(); + } - ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); - ImGui::Text("idx=%d user_data=%llx pos=(%.1f, %.1f)", - idx, (unsigned long long)n.user_data, n.x, n.y); - ImGui::PopStyleColor(); + // ---- 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:"); diff --git a/views.h b/views.h index 54c5582..89fb16c 100644 --- a/views.h +++ b/views.h @@ -3,6 +3,9 @@ #include #include +#include "types_registry.h" +#include "entity_ops.h" + struct GraphData; struct GraphViewportState; @@ -96,6 +99,43 @@ struct AppState { bool want_save_note = false; bool want_open_note = false; // doble click → cargar y abrir int open_note_target = -1; // node_idx a abrir + + // ---- Inspector editable (issue 0008) ---------------------------------- + // Schema vivo del proyecto activo (load/save desde types.yaml). + ParsedTypes parsed_types; + + // Draft del inspector — todo lo que el usuario esta editando para el + // nodo seleccionado. Se carga desde BD al cambiar la seleccion (si no + // hay cambios pendientes) y se persiste con entity_update al guardar. + int insp_node_idx = -1; + std::string insp_entity_id; + char insp_name_buf[256] = {}; + char insp_type_buf[64] = {}; + std::vector insp_desc_buf; // multiline + int insp_status_idx = 0; // 0=active 1=stale 2=corrupted 3=archived + + // Listas paralelas: keys + valores actuales de los campos de metadata. + // Las claves del schema del tipo van primero (en su orden), las "extras" + // van detras. `is_extra[i]` distingue para render diferenciado y para + // permitir borrar solo extras desde la UI. + std::vector insp_field_keys; + std::vector insp_field_values; + std::vector insp_is_extra; + + std::vector insp_tags; + char insp_tag_input[64] = {}; + char insp_extra_key[64] = {}; + + bool insp_dirty = false; + bool insp_show_unsaved = false; + int insp_pending_target = -1; + + bool want_inspector_save = false; + bool want_inspector_discard = false; + + // Caches refrescadas tras cargar grafo o tras Save. + std::vector insp_tag_suggestions; + std::vector insp_type_options; }; // Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout). @@ -133,4 +173,24 @@ void views_apply_visibility(AppState& app); // los tipos del grafo activo. Llamar tras cargar/recargar el grafo. void views_reset_visibility(AppState& app); +// ---- Inspector editable helpers (issue 0008) ------------------------------ + +// Refresca insp_tag_suggestions e insp_type_options leyendo BD y schema. +// Llamar tras cargar el grafo o tras un Save. +void views_inspector_refresh_caches(AppState& app); + +// Carga el draft del Inspector desde la BD para el nodo `node_idx`. Si el +// nodo no es resoluble o no existe, deja el draft vacio. No respeta dirty: +// el caller debe haberlo manejado ya. +void views_inspector_load_draft(AppState& app, int node_idx, + const char* entity_id); + +// Construye un EntityRecord desde el draft actual respetando el schema +// del type_ref para decidir is_string de cada metadata field. +EntityRecord views_inspector_build_record(const AppState& app); + +// Resetea el draft (todos los buffers + dirty=false). Util tras Save o +// al cambiar de proyecto. +void views_inspector_clear_draft(AppState& app); + } // namespace ge