616c46297b
Dos bugs reportados tras 0036d/0037:
1. Promote button en NodeGroups window kind=Group no respondia al
click. Causa: el Selectable con SpanAllColumns + AllowDoubleClick
se tragaba el click destinado al SmallButton(TI_ARROW_UP) sobre
la misma fila. ImGui tiene un flag dedicado para esto:
AllowOverlap. Anyadido al Selectable y los botones recuperan los
clicks. Mismo fix beneficia al kind=Table porque los botones
"promote" y "expand" de DuckDB rows estaban en la misma situacion
silenciosa.
2. Placement direccional de 0037 enviaba hijos hasta r=400 cuando
habia colisiones, "muy lejos" segun el usuario. Ajustes:
- Anillos mas cercanos: {60,90,120,150,180,210,240} en vez de
{80,140,200,280,400}. Maximo 240px del source.
- Mas anillos disponibles (7 vs 5) — fan-out gradual sin saltar
bruscamente de 80 a 140.
- Espaciado entre hermanos en arco usa cluster_min_dist=35 en
vez de min_dist=60. Permite mas hijos por anillo dentro del
arco de 45 grados (cap @ r=240 = 5 vs 3 antes).
- Para 10-15 hijos tipicos los inner rings cubren todo dentro
de 200px del source.
Build limpio. Tests WSL 102 / Windows 91 + 11 skipped.
Bonus: borrado /home/lucas/fn_registry/cpp/registry.db (vacio, 0
bytes, creado por algun binario con flag O_CREAT) — violacion de
db_locations.md (registry.db solo en raiz del repo). Era el motivo
de un test flaky de python_runtime_resolver.
2756 lines
113 KiB
C++
2756 lines
113 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);
|
|
}
|
|
// Issue 0036d: tooltip suave que explica el promote segun kind.
|
|
if (is_group) {
|
|
ImGui::TextDisabled("Promote: saca el nodo del grupo");
|
|
} else {
|
|
ImGui::TextDisabled(
|
|
"Promote: convierte fila DuckDB en entidad del grafo");
|
|
}
|
|
if (!w.last_error.empty()) {
|
|
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
|
|
"ERROR: %s", w.last_error.c_str());
|
|
}
|
|
ImGui::Separator();
|
|
|
|
// Tabla — layout depende del kind:
|
|
// Table: [id_column] + columns[] + [promoted] (col_count = N+2)
|
|
// Group: columns[] + [promote] (col_count = N+1)
|
|
// (issue 0036d: ultima columna lleva un boton TI_ARROW_UP
|
|
// para sacar la entidad del grupo.)
|
|
const int col_count = is_group
|
|
? (int)m.columns.size() + 1
|
|
: (int)m.columns.size() + 2;
|
|
ImGuiTableFlags tflags =
|
|
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
|
|
ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable |
|
|
ImGuiTableFlags_SizingStretchProp;
|
|
if (col_count > 0 && ImGui::BeginTable("##te_rows", col_count, tflags,
|
|
ImVec2(0, -ImGui::GetFrameHeightWithSpacing()))) {
|
|
ImGui::TableSetupScrollFreeze(0, 1);
|
|
if (is_group) {
|
|
for (size_t i = 0; i < m.columns.size(); ++i) {
|
|
bool is_id = (i == 0);
|
|
ImGui::TableSetupColumn(m.columns[i].c_str(),
|
|
is_id ? ImGuiTableColumnFlags_WidthFixed
|
|
: ImGuiTableColumnFlags_WidthStretch,
|
|
is_id ? 160.0f : 0.0f);
|
|
}
|
|
// 0036d: columna extra para el boton Promote-out-of-group.
|
|
ImGui::TableSetupColumn("promote",
|
|
ImGuiTableColumnFlags_WidthFixed, 60.0f);
|
|
} else {
|
|
ImGui::TableSetupColumn(m.id_column.empty() ? "id" : m.id_column.c_str(),
|
|
ImGuiTableColumnFlags_WidthFixed, 100.0f);
|
|
for (const auto& c : m.columns) {
|
|
ImGui::TableSetupColumn(c.c_str(), ImGuiTableColumnFlags_WidthStretch);
|
|
}
|
|
ImGui::TableSetupColumn("promoted",
|
|
ImGuiTableColumnFlags_WidthFixed, 80.0f);
|
|
}
|
|
ImGui::TableHeadersRow();
|
|
|
|
for (int64_t i = 0; i < (int64_t)w.page.size(); ++i) {
|
|
const NodeGroupsRow& row = w.page[i];
|
|
ImGui::TableNextRow();
|
|
ImGui::PushID((int)(w.offset + i));
|
|
|
|
ImGui::TableSetColumnIndex(0);
|
|
|
|
// Selectable spanning para que el doble-click y el right-click
|
|
// funcionen sobre toda la fila, no solo el texto.
|
|
// AllowOverlap obligatorio para que botones colocados despues
|
|
// (Promote en kind=Group, ver issue 0036d) reciban sus clicks
|
|
// en lugar de que el Selectable se los trague.
|
|
ImGuiSelectableFlags sf = ImGuiSelectableFlags_SpanAllColumns
|
|
| ImGuiSelectableFlags_AllowDoubleClick
|
|
| ImGuiSelectableFlags_AllowOverlap;
|
|
ImGui::Selectable(row.id.c_str(), false, sf);
|
|
|
|
if (is_group) {
|
|
// 0036e: en kind=Group la fila YA es una entidad real del
|
|
// grafo. Single click → focus + select en viewport.
|
|
// Doble click tambien dispara focus (mismo efecto).
|
|
// Right click → menu contextual con focus.
|
|
if (ImGui::IsItemHovered()
|
|
&& (ImGui::IsMouseClicked(0) || ImGui::IsMouseDoubleClicked(0))) {
|
|
app.want_focus_entity = true;
|
|
app.focus_entity_id = row.id;
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetTooltip("Click to focus entity in viewport");
|
|
}
|
|
if (ImGui::BeginPopupContextItem()) {
|
|
if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) {
|
|
app.want_focus_entity = true;
|
|
app.focus_entity_id = row.id;
|
|
}
|
|
ImGui::EndPopup();
|
|
}
|
|
|
|
// Render de las columnas (la 0 ya tiene el Selectable;
|
|
// el texto del id se ve en el propio Selectable).
|
|
for (size_t c = 1; c < m.columns.size(); ++c) {
|
|
ImGui::TableSetColumnIndex((int)c);
|
|
if (c < row.values.size())
|
|
ImGui::TextUnformatted(row.values[c].c_str());
|
|
}
|
|
// 0036d: boton Promote-out-of-group en la ultima columna.
|
|
ImGui::TableSetColumnIndex(col_count - 1);
|
|
if (ImGui::SmallButton(TI_ARROW_UP "##promote_grp")) {
|
|
app.want_clear_group_id_entity = true;
|
|
app.clear_group_id_entity_id = row.id;
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetTooltip("Promote out of group (move to canvas)");
|
|
}
|
|
} else {
|
|
// kind=Table (DuckDB-backed).
|
|
// 0036e: click ramificado por estado de promocion:
|
|
// - promovida → single click = focus en viewport.
|
|
// - no promovida → single click = no-op + hint tooltip.
|
|
// El doble click sobre fila no promovida sigue lanzando
|
|
// el flujo de promote (legado de 0036c) por convenience.
|
|
bool is_promoted = !row.promoted_entity_id.empty();
|
|
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
|
|
if (is_promoted) {
|
|
app.want_focus_entity = true;
|
|
app.focus_entity_id = row.promoted_entity_id;
|
|
}
|
|
// else: no-op (hint mostrado via tooltip abajo).
|
|
}
|
|
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
|
if (is_promoted) {
|
|
app.want_focus_entity = true;
|
|
app.focus_entity_id = row.promoted_entity_id;
|
|
} else {
|
|
app.want_promote_row = true;
|
|
app.promote_table_id = m.entity_id;
|
|
app.promote_row_id = row.id;
|
|
}
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
if (is_promoted) {
|
|
ImGui::SetTooltip("Click to focus entity in viewport");
|
|
} else {
|
|
ImGui::SetTooltip("promote first to focus\n(double click or right click to promote)");
|
|
}
|
|
}
|
|
if (ImGui::BeginPopupContextItem()) {
|
|
if (is_promoted) {
|
|
if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) {
|
|
app.want_focus_entity = true;
|
|
app.focus_entity_id = row.promoted_entity_id;
|
|
}
|
|
if (ImGui::MenuItem(TI_X " Demote (delete entity)")) {
|
|
app.want_demote_entity = true;
|
|
app.demote_entity_id = row.promoted_entity_id;
|
|
}
|
|
} else {
|
|
if (ImGui::MenuItem(TI_PLUS " Promote to graph node")) {
|
|
app.want_promote_row = true;
|
|
app.promote_table_id = m.entity_id;
|
|
app.promote_row_id = row.id;
|
|
}
|
|
}
|
|
ImGui::EndPopup();
|
|
}
|
|
|
|
for (size_t c = 0; c < m.columns.size(); ++c) {
|
|
ImGui::TableSetColumnIndex(1 + (int)c);
|
|
if (c < row.values.size())
|
|
ImGui::TextUnformatted(row.values[c].c_str());
|
|
}
|
|
ImGui::TableSetColumnIndex(col_count - 1);
|
|
if (is_promoted) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text,
|
|
ImVec4(0.6f, 0.95f, 0.6f, 1.0f));
|
|
ImGui::TextUnformatted("yes");
|
|
ImGui::PopStyleColor();
|
|
} else {
|
|
ImGui::TextDisabled("-");
|
|
}
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
ImGui::EndTable();
|
|
}
|
|
|
|
// Footer: paginacion manual (offset).
|
|
bool has_prev = w.offset > 0;
|
|
bool has_next = w.offset + (int64_t)w.page.size() < w.total_rows;
|
|
if (!has_prev) ImGui::BeginDisabled();
|
|
if (fn_ui::button(TI_ARROW_LEFT " Prev", fn_ui::ButtonVariant::Subtle)) {
|
|
w.offset = std::max<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
|