b2ae793727
Issue 0008 — refactor del panel Inspector de read-only a editable.
views.h:
- AppState gana ParsedTypes parsed_types (schema vivo del proyecto), draft
del Inspector (insp_*: name/type/desc/status buffers, field_keys/values
paralelas, is_extra mask, tags vector, dirty flag), y dos triggers
(want_inspector_save, want_inspector_discard).
- Helpers expuestos: views_inspector_clear_draft, _refresh_caches,
_load_draft, _build_record.
views.cpp:
- views_inspector_load_draft: entity_load_full → buffers; campos del
schema primero (orden del EntitySpec), extras detras.
- views_inspector_build_record: reconstruye EntityRecord respetando el
schema para decidir is_string de cada campo (FK_BOOL → 'true'/'false',
FK_INT/FLOAT → literal, resto → string). Extras siempre string.
- views_inspector: render por bloques:
* Identity: name, type combo (lista del proyecto + tipos del grafo),
status combo, description multiline.
* Fields del schema: render por kind (string→InputText con hint,
int→InputInt, float→InputDouble, bool→Checkbox, date→InputText
con hint YYYY-MM-DD, url→InputText + boton Open en navegador,
enum→Combo con values). Required marcado con '*'.
* Extras: lista key-value con boton trash por fila + 'Add' al final.
* Tags: chips clickables (click = quitar) + input con autocomplete
(lista compacta de tags distintas en BD).
* Footer: Save/Discard/Open notes + label '(modified)' si dirty.
* Neighbors read-only (igual que antes).
- Si el draft no esta sincronizado con la seleccion actual y NO hay
cambios pendientes, el inspector muestra 'Cargando...' (main.cpp
carga). Si hay dirty, banner 'Save/Discard primero' bloqueando.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1157 lines
43 KiB
C++
1157 lines
43 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 "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>
|
|
|
|
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;
|
|
}
|
|
|
|
void views_apply_visibility(AppState& app) {
|
|
if (!app.graph) return;
|
|
GraphData& g = *app.graph;
|
|
for (int i = 0; i < g.node_count; ++i) {
|
|
uint16_t t = g.nodes[i].type_id;
|
|
bool vis = (t < (uint16_t)app.type_visible_n) ? app.type_visible[t] : true;
|
|
if (vis) g.nodes[i].flags |= NF_VISIBLE;
|
|
else g.nodes[i].flags &= ~NF_VISIBLE;
|
|
}
|
|
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;
|
|
// Si los endpoints estan ocultos, la arista tambien.
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Toolbar
|
|
// ----------------------------------------------------------------------------
|
|
|
|
void views_toolbar(AppState& app) {
|
|
using namespace fn_ui;
|
|
toolbar_begin();
|
|
// Project switcher — etiqueta = proyecto activo. Click abre popup con
|
|
// New / Open / Recent / Reveal. Si no hay proyecto activo (modo
|
|
// legacy con --input directo), muestra "(no project)".
|
|
{
|
|
char btn_label[128];
|
|
const char* p = app.active_project.empty()
|
|
? "(no project)" : app.active_project.c_str();
|
|
std::snprintf(btn_label, sizeof(btn_label), TI_FOLDER " Project: %s", p);
|
|
if (button(btn_label, ButtonVariant::Secondary)) {
|
|
// Refresca caches al abrir
|
|
project_list(&app.project_list_cache);
|
|
ProjectSettings ps;
|
|
project_settings_load(&ps);
|
|
app.project_recent_cache = ps.recent;
|
|
ImGui::OpenPopup("##project_menu");
|
|
}
|
|
if (ImGui::BeginPopup("##project_menu")) {
|
|
if (ImGui::MenuItem(TI_PLUS " New project...")) {
|
|
app.show_new_project_modal = true;
|
|
app.new_project_buf[0] = 0;
|
|
app.new_project_error.clear();
|
|
}
|
|
ImGui::Separator();
|
|
|
|
// Recent submenu
|
|
if (!app.project_recent_cache.empty()) {
|
|
if (ImGui::BeginMenu("Recent")) {
|
|
for (const auto& slug : app.project_recent_cache) {
|
|
bool is_active = (slug == app.active_project);
|
|
if (ImGui::MenuItem(slug.c_str(), nullptr, is_active)
|
|
&& !is_active) {
|
|
app.want_switch_project = true;
|
|
app.switch_project_target = slug;
|
|
}
|
|
}
|
|
ImGui::EndMenu();
|
|
}
|
|
}
|
|
|
|
// Open submenu — todos los detectados
|
|
if (ImGui::BeginMenu("Open")) {
|
|
if (app.project_list_cache.empty()) {
|
|
ImGui::TextDisabled("(no projects yet)");
|
|
} else {
|
|
for (const auto& slug : app.project_list_cache) {
|
|
bool is_active = (slug == app.active_project);
|
|
if (ImGui::MenuItem(slug.c_str(), nullptr, is_active)
|
|
&& !is_active) {
|
|
app.want_switch_project = true;
|
|
app.switch_project_target = slug;
|
|
}
|
|
}
|
|
}
|
|
ImGui::EndMenu();
|
|
}
|
|
|
|
ImGui::Separator();
|
|
bool can_reveal = !app.active_project.empty();
|
|
if (ImGui::MenuItem(TI_FOLDER_OPEN " Reveal in explorer",
|
|
nullptr, false, can_reveal)) {
|
|
project_reveal_in_explorer(app.active_project.c_str());
|
|
}
|
|
ImGui::EndPopup();
|
|
}
|
|
}
|
|
ImGui::SameLine();
|
|
toolbar_separator();
|
|
|
|
if (button(TI_FOLDER " Open file...", ButtonVariant::Secondary)) {
|
|
app.show_open_modal = true;
|
|
}
|
|
ImGui::SameLine();
|
|
|
|
// Add node — input + auto-deteccion de tipo. Enter o boton "Add" lo
|
|
// confirman; main.cpp inserta en operations.db y dispara reload.
|
|
ImGui::SetNextItemWidth(220);
|
|
DetectedType dt = detect_type(app.add_buf);
|
|
ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue;
|
|
char hint[64];
|
|
std::snprintf(hint, sizeof(hint), "Add node (%s)...", detected_type_name(dt));
|
|
if (ImGui::InputTextWithHint("##addnode", hint, app.add_buf, sizeof(app.add_buf), flags)) {
|
|
app.want_add_node = true;
|
|
}
|
|
ImGui::SameLine();
|
|
if (button(TI_PLUS " Add", ButtonVariant::Primary)) {
|
|
app.want_add_node = true;
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("[%s]", detected_type_name(dt));
|
|
toolbar_separator();
|
|
|
|
ImGui::TextUnformatted("Layout:");
|
|
ImGui::SameLine();
|
|
ImGui::SetNextItemWidth(140);
|
|
int idx = app.layout_mode;
|
|
if (ImGui::Combo("##layout", &idx, k_layout_names, k_layout_count)) {
|
|
if (idx != app.layout_mode) {
|
|
app.layout_mode = idx;
|
|
++app.apply_layout_tick;
|
|
}
|
|
}
|
|
toolbar_separator();
|
|
|
|
if (button(TI_FILTER " Filters...", ButtonVariant::Subtle)) {
|
|
app.show_filters_modal = true;
|
|
}
|
|
if (button(TI_ARROWS_MAXIMIZE " Fit view", ButtonVariant::Subtle)) {
|
|
app.want_fit = true;
|
|
}
|
|
if (button(TI_DEVICE_FLOPPY " Save layout", ButtonVariant::Subtle)) {
|
|
app.want_save_layout = true;
|
|
}
|
|
if (button(TI_REFRESH " Reload", ButtonVariant::Subtle)) {
|
|
app.want_reload = true;
|
|
}
|
|
if (button(TI_LAYOUT_GRID " Reset layout", ButtonVariant::Subtle)) {
|
|
app.want_unpin_all = true;
|
|
++app.apply_layout_tick;
|
|
}
|
|
toolbar_separator();
|
|
|
|
ImGui::Checkbox("GPU layout", &app.use_gpu);
|
|
ImGui::SameLine();
|
|
ImGui::Checkbox("Labels", &app.labels_enabled);
|
|
if (app.viewport) {
|
|
ImGui::SameLine();
|
|
ImGui::Checkbox("Run layout", &app.viewport->layout_running);
|
|
}
|
|
toolbar_end();
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Legend
|
|
// ----------------------------------------------------------------------------
|
|
|
|
void views_legend(AppState& app) {
|
|
if (!app.panel_legend) return;
|
|
if (!ImGui::Begin("Legend", &app.panel_legend)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
if (!app.graph) {
|
|
ImGui::TextUnformatted("(no graph loaded)");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
GraphData& g = *app.graph;
|
|
bool changed = false;
|
|
|
|
ImGui::TextUnformatted("Entity types");
|
|
ImGui::Separator();
|
|
for (int i = 0; i < g.type_count && i < app.type_visible_n; ++i) {
|
|
const EntityType& et = g.types[i];
|
|
color_swatch(et.color);
|
|
char id[32];
|
|
std::snprintf(id, sizeof(id), "##t%d", i);
|
|
bool v = app.type_visible[i];
|
|
if (ImGui::Checkbox(id, &v)) {
|
|
app.type_visible[i] = v;
|
|
changed = true;
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(et.name ? et.name : "(unnamed)");
|
|
}
|
|
|
|
if (g.rel_type_count > 0) {
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted("Relation types");
|
|
ImGui::Separator();
|
|
for (int i = 0; i < g.rel_type_count && i < app.rel_type_visible_n; ++i) {
|
|
const RelationType& rt = g.rel_types[i];
|
|
color_swatch(rt.color);
|
|
char id[32];
|
|
std::snprintf(id, sizeof(id), "##r%d", i);
|
|
bool v = app.rel_type_visible[i];
|
|
if (ImGui::Checkbox(id, &v)) {
|
|
app.rel_type_visible[i] = v;
|
|
changed = true;
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(rt.name ? rt.name : "(unnamed)");
|
|
}
|
|
}
|
|
|
|
if (changed) views_apply_visibility(app);
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Inspector helpers (issue 0008)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
namespace {
|
|
|
|
const char* k_status_options[] = { "active", "stale", "corrupted", "archived" };
|
|
constexpr int k_status_count = 4;
|
|
|
|
int status_to_idx(const std::string& s) {
|
|
for (int i = 0; i < k_status_count; ++i) {
|
|
if (s == k_status_options[i]) return i;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
const EntitySpec* find_entity_spec(const ParsedTypes& pt, const char* name) {
|
|
if (!name || !*name) return nullptr;
|
|
auto eq_ci = [&](const std::string& a, const char* b) {
|
|
if (a.size() != std::strlen(b)) return false;
|
|
for (size_t i = 0; i < a.size(); ++i) {
|
|
if (std::tolower((unsigned char)a[i]) !=
|
|
std::tolower((unsigned char)b[i])) return false;
|
|
}
|
|
return true;
|
|
};
|
|
for (const auto& e : pt.entities) {
|
|
if (eq_ci(e.name, name)) return &e;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
// Asegura que el buffer de descripcion tiene al menos `need` bytes (con NUL).
|
|
void ensure_desc_buf(std::vector<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 ----
|
|
ImGui::TextUnformatted("Identity");
|
|
ImGui::Separator();
|
|
if (ImGui::InputText("name", app.insp_name_buf, sizeof(app.insp_name_buf)))
|
|
any_change = true;
|
|
|
|
// type combo
|
|
{
|
|
int cur = -1;
|
|
for (size_t i = 0; i < app.insp_type_options.size(); ++i) {
|
|
if (app.insp_type_options[i] == app.insp_type_buf) { cur = (int)i; break; }
|
|
}
|
|
// Si el tipo no esta en el cache (raro), mostrar como tal y permitir
|
|
// introducirlo via input. Combo simple aqui.
|
|
if (ImGui::BeginCombo("type", app.insp_type_buf)) {
|
|
for (size_t i = 0; i < app.insp_type_options.size(); ++i) {
|
|
bool is_sel = (int)i == cur;
|
|
if (ImGui::Selectable(app.insp_type_options[i].c_str(), is_sel)) {
|
|
copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf),
|
|
app.insp_type_options[i]);
|
|
any_change = true;
|
|
}
|
|
if (is_sel) ImGui::SetItemDefaultFocus();
|
|
}
|
|
ImGui::EndCombo();
|
|
}
|
|
}
|
|
// status combo
|
|
if (ImGui::Combo("status", &app.insp_status_idx,
|
|
k_status_options, k_status_count))
|
|
any_change = true;
|
|
// description multiline
|
|
if (app.insp_desc_buf.empty()) ensure_desc_buf(app.insp_desc_buf, 4096);
|
|
if (ImGui::InputTextMultiline("description",
|
|
app.insp_desc_buf.data(),
|
|
app.insp_desc_buf.size(),
|
|
ImVec2(-FLT_MIN, 60.0f)))
|
|
any_change = true;
|
|
|
|
// ---- Schema fields + Extras ----
|
|
if (!app.insp_field_keys.empty()) {
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted("Fields");
|
|
ImGui::Separator();
|
|
const EntitySpec* spec = find_entity_spec(app.parsed_types,
|
|
app.insp_type_buf);
|
|
for (size_t i = 0; i < app.insp_field_keys.size(); ++i) {
|
|
const std::string& key = app.insp_field_keys[i];
|
|
std::string& val = app.insp_field_values[i];
|
|
bool is_extra = app.insp_is_extra[i] != 0;
|
|
ImGui::PushID((int)i);
|
|
|
|
// Encuentra la FieldSpec si es del schema.
|
|
const FieldSpec* fs = nullptr;
|
|
if (!is_extra && spec) {
|
|
for (const auto& f : spec->fields) {
|
|
if (f.name == key) { fs = &f; break; }
|
|
}
|
|
}
|
|
|
|
FieldKind kind = fs ? fs->kind : FK_STRING;
|
|
std::string label = key;
|
|
if (fs && fs->required) label += " *";
|
|
if (is_extra) label = "[extra] " + key;
|
|
|
|
char buf[1024];
|
|
size_t k = std::min(sizeof(buf) - 1, val.size());
|
|
std::memcpy(buf, val.data(), k);
|
|
buf[k] = 0;
|
|
|
|
bool changed = false;
|
|
switch (kind) {
|
|
case FK_BOOL: {
|
|
bool b = (val == "true" || val == "1");
|
|
if (ImGui::Checkbox(label.c_str(), &b)) {
|
|
val = b ? "true" : "false";
|
|
changed = true;
|
|
}
|
|
break;
|
|
}
|
|
case FK_INT: {
|
|
int n = std::atoi(val.c_str());
|
|
if (ImGui::InputInt(label.c_str(), &n)) {
|
|
char nb[32]; std::snprintf(nb, sizeof(nb), "%d", n);
|
|
val = nb;
|
|
changed = true;
|
|
}
|
|
break;
|
|
}
|
|
case FK_FLOAT: {
|
|
double d = std::atof(val.c_str());
|
|
if (ImGui::InputDouble(label.c_str(), &d, 0.0, 0.0, "%.6g")) {
|
|
char nb[64]; std::snprintf(nb, sizeof(nb), "%.10g", d);
|
|
val = nb;
|
|
changed = true;
|
|
}
|
|
break;
|
|
}
|
|
case FK_ENUM: {
|
|
if (fs && !fs->enum_values.empty()) {
|
|
int cur = -1;
|
|
for (size_t e = 0; e < fs->enum_values.size(); ++e) {
|
|
if (fs->enum_values[e] == val) { cur = (int)e; break; }
|
|
}
|
|
if (ImGui::BeginCombo(label.c_str(), val.c_str())) {
|
|
for (size_t e = 0; e < fs->enum_values.size(); ++e) {
|
|
bool is_sel = (int)e == cur;
|
|
if (ImGui::Selectable(fs->enum_values[e].c_str(), is_sel)) {
|
|
val = fs->enum_values[e];
|
|
changed = true;
|
|
}
|
|
if (is_sel) ImGui::SetItemDefaultFocus();
|
|
}
|
|
ImGui::EndCombo();
|
|
}
|
|
} else {
|
|
// Sin valores: tratar como string
|
|
if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) {
|
|
val = buf;
|
|
changed = true;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case FK_URL:
|
|
if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) {
|
|
val = buf;
|
|
changed = true;
|
|
}
|
|
if (!val.empty() &&
|
|
(val.rfind("http://", 0) == 0 || val.rfind("https://", 0) == 0)) {
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton("Open##url")) {
|
|
#if defined(_WIN32)
|
|
std::string cmd = "start \"\" \"" + val + "\"";
|
|
#else
|
|
std::string cmd = "xdg-open '" + val + "' >/dev/null 2>&1 &";
|
|
#endif
|
|
int rc = std::system(cmd.c_str()); (void)rc;
|
|
}
|
|
}
|
|
break;
|
|
case FK_DATE:
|
|
case FK_STRING:
|
|
default:
|
|
if (ImGui::InputTextWithHint(label.c_str(),
|
|
kind == FK_DATE ? "YYYY-MM-DD" : "",
|
|
buf, sizeof(buf))) {
|
|
val = buf;
|
|
changed = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (is_extra) {
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton(TI_TRASH "##rm")) {
|
|
app.insp_field_keys.erase(app.insp_field_keys.begin() + i);
|
|
app.insp_field_values.erase(app.insp_field_values.begin() + i);
|
|
app.insp_is_extra.erase(app.insp_is_extra.begin() + i);
|
|
ImGui::PopID();
|
|
any_change = true;
|
|
--i;
|
|
continue;
|
|
}
|
|
}
|
|
if (changed) any_change = true;
|
|
ImGui::PopID();
|
|
}
|
|
}
|
|
|
|
// ---- Add extra ----
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted("Extra field");
|
|
ImGui::Separator();
|
|
ImGui::SetNextItemWidth(160);
|
|
ImGui::InputTextWithHint("##xkey", "key", app.insp_extra_key,
|
|
sizeof(app.insp_extra_key));
|
|
ImGui::SameLine();
|
|
if (fn_ui::button(TI_PLUS " Add", fn_ui::ButtonVariant::Subtle)) {
|
|
if (app.insp_extra_key[0]) {
|
|
// Evitar colision con keys ya presentes
|
|
std::string k = app.insp_extra_key;
|
|
bool dup = false;
|
|
for (const auto& x : app.insp_field_keys) {
|
|
if (x == k) { dup = true; break; }
|
|
}
|
|
if (!dup) {
|
|
app.insp_field_keys.push_back(k);
|
|
app.insp_field_values.emplace_back("");
|
|
app.insp_is_extra.push_back(1);
|
|
any_change = true;
|
|
}
|
|
app.insp_extra_key[0] = 0;
|
|
}
|
|
}
|
|
|
|
// ---- Tags ----
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted("Tags");
|
|
ImGui::Separator();
|
|
for (size_t i = 0; i < app.insp_tags.size(); ++i) {
|
|
ImGui::PushID((int)i);
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.30f, 0.50f, 1.0f));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.40f, 0.65f, 1.0f));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.35f, 0.50f, 0.75f, 1.0f));
|
|
std::string lbl = app.insp_tags[i] + " x";
|
|
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();
|
|
if (fn_ui::button("Add tag", fn_ui::ButtonVariant::Subtle)) commit_tag = true;
|
|
if (commit_tag && app.insp_tag_input[0]) {
|
|
std::string t = app.insp_tag_input;
|
|
bool dup = false;
|
|
for (const auto& x : app.insp_tags) if (x == t) { dup = true; break; }
|
|
if (!dup) {
|
|
app.insp_tags.push_back(t);
|
|
any_change = true;
|
|
}
|
|
app.insp_tag_input[0] = 0;
|
|
}
|
|
if (!app.insp_tag_suggestions.empty()) {
|
|
ImGui::TextDisabled("(in db: %zu tags distintas)", app.insp_tag_suggestions.size());
|
|
// Lista compacta clickable de las primeras 12 sugerencias.
|
|
int shown = 0;
|
|
for (const auto& s : app.insp_tag_suggestions) {
|
|
if (shown >= 12) { ImGui::TextDisabled("..."); break; }
|
|
// Ocultar las ya presentes
|
|
bool already = false;
|
|
for (const auto& x : app.insp_tags) if (x == s) { already = true; break; }
|
|
if (already) continue;
|
|
ImGui::SmallButton(s.c_str());
|
|
if (ImGui::IsItemClicked()) {
|
|
app.insp_tags.push_back(s);
|
|
any_change = true;
|
|
}
|
|
ImGui::SameLine();
|
|
++shown;
|
|
}
|
|
if (shown > 0) ImGui::NewLine();
|
|
}
|
|
|
|
// ---- Footer: Save / Discard / Notes / Neighbors ----
|
|
ImGui::Spacing();
|
|
ImGui::Separator();
|
|
if (any_change) app.insp_dirty = true;
|
|
|
|
if (app.insp_dirty) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f));
|
|
ImGui::TextUnformatted("(modified)");
|
|
ImGui::PopStyleColor();
|
|
}
|
|
if (fn_ui::button(TI_DEVICE_FLOPPY " Save",
|
|
app.insp_dirty ? fn_ui::ButtonVariant::Primary
|
|
: fn_ui::ButtonVariant::Subtle)) {
|
|
app.want_inspector_save = true;
|
|
}
|
|
ImGui::SameLine();
|
|
if (fn_ui::button("Discard", fn_ui::ButtonVariant::Subtle)) {
|
|
app.want_inspector_discard = true;
|
|
}
|
|
ImGui::SameLine();
|
|
if (fn_ui::button(TI_FILE_TEXT " Open notes", fn_ui::ButtonVariant::Subtle)) {
|
|
app.want_open_note = true;
|
|
app.open_note_target = idx;
|
|
}
|
|
|
|
// ---- Neighbors (read-only) ----
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
|
|
ImGui::TextUnformatted("Neighbors:");
|
|
ImGui::PopStyleColor();
|
|
|
|
int neighbor_count = 0;
|
|
for (int e = 0; e < g.edge_count && neighbor_count < 64; ++e) {
|
|
const GraphEdge& edge = g.edges[e];
|
|
int other = -1;
|
|
const char* arrow = " ";
|
|
if (edge.source == (uint32_t)idx) { other = (int)edge.target; arrow = "->"; }
|
|
else if (edge.target == (uint32_t)idx) { other = (int)edge.source; arrow = "<-"; }
|
|
if (other < 0 || other >= g.node_count) continue;
|
|
const char* olbl = graph::graph_label(&g, g.nodes[other].label_idx);
|
|
const char* rname = (edge.type_id < (uint16_t)g.rel_type_count &&
|
|
g.rel_types[edge.type_id].name)
|
|
? g.rel_types[edge.type_id].name
|
|
: k_default_relation_name;
|
|
char buf[256];
|
|
std::snprintf(buf, sizeof(buf), "%s %s [%d] %s", arrow, rname, other,
|
|
olbl && *olbl ? olbl : "(unnamed)");
|
|
if (ImGui::Selectable(buf)) {
|
|
graph_viewport_clear_selection(g, *app.viewport);
|
|
graph_viewport_add_to_selection(g, *app.viewport, other);
|
|
}
|
|
++neighbor_count;
|
|
}
|
|
if (neighbor_count == 0)
|
|
ImGui::TextDisabled("(none)");
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Stats
|
|
// ----------------------------------------------------------------------------
|
|
|
|
void views_stats(AppState& app) {
|
|
if (!app.panel_stats) return;
|
|
if (!ImGui::Begin("Stats", &app.panel_stats)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
if (!app.graph || !app.viewport) {
|
|
ImGui::TextUnformatted("(no graph)");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
int sel = (int)app.viewport->selection.size();
|
|
ImGui::Text("nodes=%d edges=%d types=%d rel_types=%d",
|
|
app.graph->node_count, app.graph->edge_count,
|
|
app.graph->type_count, app.graph->rel_type_count);
|
|
ImGui::Text("fps=%d energy=%.4f selection=%d",
|
|
app.fps_estimate, app.viewport->layout_energy, sel);
|
|
ImGui::Text("layout=%s mode=%s",
|
|
k_layout_names[app.layout_mode % k_layout_count],
|
|
app.use_gpu ? "GPU" : "CPU");
|
|
ImGui::End();
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Note editor (markdown)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
void views_note(AppState& app) {
|
|
if (!app.panel_note) return;
|
|
if (!ImGui::Begin(TI_FILE_TEXT " Note", &app.panel_note,
|
|
ImGuiWindowFlags_MenuBar)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
if (ImGui::BeginMenuBar()) {
|
|
if (ImGui::MenuItem(TI_DEVICE_FLOPPY " Save", "Ctrl+S",
|
|
/*selected=*/false, app.note_dirty)) {
|
|
app.want_save_note = true;
|
|
}
|
|
if (app.note_dirty) {
|
|
ImGui::TextDisabled("(modified)");
|
|
}
|
|
ImGui::EndMenuBar();
|
|
}
|
|
|
|
if (app.note_node < 0) {
|
|
ImGui::TextDisabled("Doble click sobre un nodo para abrir su nota.");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
|
|
ImGui::TextUnformatted("entity:");
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(app.note_entity_label.empty()
|
|
? "(unnamed)" : app.note_entity_label.c_str());
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
|
|
ImGui::TextUnformatted("type:");
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(app.note_entity_type.empty()
|
|
? "(no-type)" : app.note_entity_type.c_str());
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
|
|
ImGui::TextUnformatted("id:");
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(app.note_entity_id.c_str());
|
|
|
|
ImGui::Separator();
|
|
|
|
if (app.note_buf.empty()) app.note_buf.resize(1024, 0);
|
|
|
|
// Crece el buffer si esta cerca del limite.
|
|
if (std::strlen(app.note_buf.data()) + 64 >= app.note_buf.size()) {
|
|
app.note_buf.resize(app.note_buf.size() * 2, 0);
|
|
}
|
|
|
|
ImVec2 size = ImGui::GetContentRegionAvail();
|
|
if (ImGui::InputTextMultiline("##note_md", app.note_buf.data(),
|
|
app.note_buf.size(), size,
|
|
ImGuiInputTextFlags_AllowTabInput)) {
|
|
app.note_dirty = true;
|
|
}
|
|
if (ImGui::IsItemHovered() &&
|
|
ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_S)) {
|
|
app.want_save_note = true;
|
|
}
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Modals
|
|
// ----------------------------------------------------------------------------
|
|
|
|
bool views_filters_modal(AppState& app) {
|
|
if (!app.show_filters_modal) return false;
|
|
bool changed = false;
|
|
if (fn_ui::modal_dialog_begin("Filters", &app.show_filters_modal,
|
|
ImVec2(520, 0))) {
|
|
if (!app.graph) {
|
|
ImGui::TextUnformatted("(no graph)");
|
|
} else {
|
|
ImGui::TextUnformatted("Entity types");
|
|
ImGui::Separator();
|
|
int nt = app.type_visible_n;
|
|
ImGui::Columns(2, "##fent", false);
|
|
for (int i = 0; i < nt; ++i) {
|
|
color_swatch(app.graph->types[i].color);
|
|
char id[32];
|
|
std::snprintf(id, sizeof(id), "##fe%d", i);
|
|
bool v = app.type_visible[i];
|
|
if (ImGui::Checkbox(id, &v)) {
|
|
app.type_visible[i] = v;
|
|
changed = true;
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(app.graph->types[i].name ? app.graph->types[i].name : "?");
|
|
ImGui::NextColumn();
|
|
}
|
|
ImGui::Columns(1);
|
|
|
|
if (app.graph->rel_type_count > 0) {
|
|
ImGui::Spacing();
|
|
ImGui::TextUnformatted("Relation types");
|
|
ImGui::Separator();
|
|
int nr = app.rel_type_visible_n;
|
|
ImGui::Columns(2, "##frel", false);
|
|
for (int i = 0; i < nr; ++i) {
|
|
color_swatch(app.graph->rel_types[i].color);
|
|
char id[32];
|
|
std::snprintf(id, sizeof(id), "##fr%d", i);
|
|
bool v = app.rel_type_visible[i];
|
|
if (ImGui::Checkbox(id, &v)) {
|
|
app.rel_type_visible[i] = v;
|
|
changed = true;
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted(app.graph->rel_types[i].name ? app.graph->rel_types[i].name : "?");
|
|
ImGui::NextColumn();
|
|
}
|
|
ImGui::Columns(1);
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
if (fn_ui::button("Show all", fn_ui::ButtonVariant::Subtle)) {
|
|
for (int i = 0; i < app.type_visible_n; ++i) app.type_visible[i] = true;
|
|
for (int i = 0; i < app.rel_type_visible_n; ++i) app.rel_type_visible[i] = true;
|
|
changed = true;
|
|
}
|
|
ImGui::SameLine();
|
|
if (fn_ui::button("Hide all", fn_ui::ButtonVariant::Subtle)) {
|
|
for (int i = 0; i < app.type_visible_n; ++i) app.type_visible[i] = false;
|
|
for (int i = 0; i < app.rel_type_visible_n; ++i) app.rel_type_visible[i] = false;
|
|
changed = true;
|
|
}
|
|
ImGui::SameLine();
|
|
if (fn_ui::button("Close", fn_ui::ButtonVariant::Primary)) {
|
|
app.show_filters_modal = false;
|
|
}
|
|
}
|
|
}
|
|
fn_ui::modal_dialog_end();
|
|
if (changed) views_apply_visibility(app);
|
|
return changed;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Modal: New project
|
|
// ----------------------------------------------------------------------------
|
|
|
|
bool views_new_project_modal(AppState& app) {
|
|
if (!app.show_new_project_modal) return false;
|
|
bool created = false;
|
|
if (fn_ui::modal_dialog_begin("New project", &app.show_new_project_modal,
|
|
ImVec2(480, 0))) {
|
|
ImGui::TextWrapped(
|
|
"Crea una subcarpeta en projects/ con su propio operations.db,"
|
|
" types.yaml y graph_explorer.db.");
|
|
ImGui::Spacing();
|
|
fn_ui::text_input("Slug", app.new_project_buf, sizeof(app.new_project_buf),
|
|
"caso_aurgi");
|
|
ImGui::TextDisabled("a-z, 0-9, '_' y '-' (max 64)");
|
|
|
|
if (!app.new_project_error.empty()) {
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.5f, 0.4f, 1.0f));
|
|
ImGui::TextWrapped("%s", app.new_project_error.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
if (fn_ui::button("Create", fn_ui::ButtonVariant::Primary)) {
|
|
std::string err;
|
|
if (!project_validate_slug(app.new_project_buf, &err)) {
|
|
app.new_project_error = err;
|
|
} else if (project_exists(app.new_project_buf)) {
|
|
app.new_project_error = "ya existe un proyecto con ese slug";
|
|
} else if (!project_create(app.new_project_buf, &err)) {
|
|
app.new_project_error = err;
|
|
} else {
|
|
// Switch al recien creado
|
|
app.want_switch_project = true;
|
|
app.switch_project_target = app.new_project_buf;
|
|
app.show_new_project_modal = false;
|
|
app.new_project_error.clear();
|
|
created = true;
|
|
}
|
|
}
|
|
ImGui::SameLine();
|
|
if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) {
|
|
app.show_new_project_modal = false;
|
|
app.new_project_error.clear();
|
|
}
|
|
}
|
|
fn_ui::modal_dialog_end();
|
|
return created;
|
|
}
|
|
|
|
bool views_open_modal(AppState& app) {
|
|
if (!app.show_open_modal) return false;
|
|
bool opened = false;
|
|
if (fn_ui::modal_dialog_begin("Open file", &app.show_open_modal,
|
|
ImVec2(520, 0))) {
|
|
ImGui::TextWrapped("Path to operations.db (or any supported source).");
|
|
ImGui::Spacing();
|
|
fn_ui::text_input("Path", app.open_buf, sizeof(app.open_buf),
|
|
"apps/registry_dashboard/operations.db");
|
|
ImGui::Spacing();
|
|
if (fn_ui::button("Open", fn_ui::ButtonVariant::Primary)) {
|
|
if (app.open_buf[0]) {
|
|
app.want_open_file = true;
|
|
app.show_open_modal = false;
|
|
opened = true;
|
|
}
|
|
}
|
|
ImGui::SameLine();
|
|
if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) {
|
|
app.show_open_modal = false;
|
|
}
|
|
}
|
|
fn_ui::modal_dialog_end();
|
|
return opened;
|
|
}
|
|
|
|
} // namespace ge
|