From adde3026ea6843ee708dbc83e96f4459a5e3f5e4 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Thu, 30 Apr 2026 23:11:48 +0200 Subject: [PATCH] fix: docking gaps + hover radius + node spread + markdown notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes - ImGui ID conflict en menu Change type: dedup tipos del grafo + defaults; PushID/PopID por entrada. - Dockspace ya no tapa la toolbar: se posiciona 44 px por debajo, asi las ventanas dockeadas al borde superior quedan bajo la barra de filtros, no detras. - Hover radius proporcional al tamaño visual del nodo: query espacial amplio (24/zoom) + filtro fino por (radio_visual + 2 px) / zoom. El tooltip solo se dispara si el raton esta efectivamente sobre el nodo. Layout - Default layout = grid (en vez de force) para que los grafos cargados se distribuyan ordenadamente al abrir. - Boton "Reset layout" en la toolbar: limpia NF_PINNED en todos los nodos, resetea velocidades y reaplica el layout activo. - Nodos recien creados (add_node, duplicate) caen en un anillo poisson alrededor del centro de la vista, no en el origen. Posicion determinista por user_data para que el mismo nodo no salte entre reloads. Notes (markdown) - Panel "Note" (dockeable) abierto con doble click sobre un nodo. - entity_get_notes / entity_set_notes en entity_ops sobre la columna `notes` de operations.db (ya existente en el schema). - Ctrl+S guarda. Cabecera muestra entity, type, id. --- entity_ops.cpp | 43 ++++++++++++++ entity_ops.h | 7 +++ main.cpp | 151 +++++++++++++++++++++++++++++++++++++++++-------- views.cpp | 76 +++++++++++++++++++++++++ views.h | 23 +++++++- 5 files changed, 273 insertions(+), 27 deletions(-) diff --git a/entity_ops.cpp b/entity_ops.cpp index 1c0c6eb..dc95406 100644 --- a/entity_ops.cpp +++ b/entity_ops.cpp @@ -286,6 +286,49 @@ bool relation_insert(const char* db_path, const char* from_id, const char* to_id return ok; } +// ---------------------------------------------------------------------------- +// Notes (markdown) +// ---------------------------------------------------------------------------- + +bool entity_get_notes(const char* db_path, const char* id, std::string* out) { + if (!db_path || !id || !out) return false; + out->clear(); + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, "SELECT notes FROM entities WHERE id = ?", -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT); + bool ok = false; + if (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* p = sqlite3_column_text(st, 0); + if (p) *out = (const char*)p; + ok = true; + } + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +bool entity_set_notes(const char* db_path, const char* id, const char* notes) { + if (!db_path || !id) return false; + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + std::string ts = now_iso(); + const char* p[3] = { notes ? notes : "", ts.c_str(), id }; + bool ok = exec_one(db, "UPDATE entities SET notes = ?, updated_at = ? WHERE id = ?", p, 3); + sqlite3_close(db); + return ok; +} + // ---------------------------------------------------------------------------- // Index user_data -> sql id // ---------------------------------------------------------------------------- diff --git a/entity_ops.h b/entity_ops.h index fb9dbce..978e6f1 100644 --- a/entity_ops.h +++ b/entity_ops.h @@ -51,6 +51,13 @@ bool relation_insert(const char* db_path, const char* from_id, const char* to_id, const char* name); +// Lee la columna `notes` (markdown) de una entidad. out se reasigna; vacio si +// no existe la entidad. +bool entity_get_notes(const char* db_path, const char* id, std::string* out); + +// Sobrescribe `notes` con el contenido proporcionado. Toca `updated_at`. +bool entity_set_notes(const char* db_path, const char* id, const char* notes); + // Mapa user_data (FNV1a hash) -> sql id. Se reconstruye despues de cada // carga del grafo (graph_sources usa FNV1a sobre id como user_data). struct EntityIndex { diff --git a/main.cpp b/main.cpp index 4fe8eec..a93b2b4 100644 --- a/main.cpp +++ b/main.cpp @@ -7,6 +7,7 @@ #include "core/panel_menu.h" #include "core/button.h" #include "core/tokens.h" +#include "core/icons_tabler.h" #include "viz/graph_types.h" #include "viz/graph_viewport.h" @@ -28,7 +29,9 @@ #include #include #include +#include #include +#include // ---------------------------------------------------------------------------- // Estado global de la app @@ -246,6 +249,13 @@ static void update_fps() { // Context menu callback (right-click sobre nodo) // ---------------------------------------------------------------------------- +// Doble click sobre nodo: solicita abrir el panel Note. main.cpp procesa +// despues (necesita acceso al EntityIndex para resolver el sql id). +static void on_double_click_cb(int node_idx, void* /*user*/) { + g_app.want_open_note = true; + g_app.open_note_target = node_idx; +} + static void on_context_menu_cb(int node_idx, ImVec2 /*screen_pos*/, void* /*user*/) { g_app.ctx_node = node_idx; g_app.ctx_open_request = true; @@ -287,23 +297,28 @@ static void render_context_menu() { ImGui::Separator(); if (ImGui::BeginMenu("Change type")) { - // Tipos del grafo actual + // Construye un set ordenado y deduplicado: tipos del grafo + defaults. + // Asi evitamos colisiones de ID en ImGui ("person" en grafo y default). + std::vector all; + all.reserve(g_graph.type_count + k_default_types_n); for (int i = 0; i < g_graph.type_count; ++i) { - const char* name = g_graph.types[i].name; - if (!name) continue; - if (ImGui::MenuItem(name)) { - std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", name); - g_app.want_change_type = true; + if (g_graph.types[i].name && *g_graph.types[i].name) { + all.push_back(g_graph.types[i].name); } } - ImGui::Separator(); - // Defaults extra (por si no estan presentes en el grafo cargado) for (int i = 0; i < k_default_types_n; ++i) { - if (ImGui::MenuItem(k_default_types[i])) { - std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", - k_default_types[i]); + const char* d = k_default_types[i]; + bool dup = false; + for (const char* x : all) { if (std::strcmp(x, d) == 0) { dup = true; break; } } + if (!dup) all.push_back(d); + } + for (size_t i = 0; i < all.size(); ++i) { + ImGui::PushID((int)i); + if (ImGui::MenuItem(all[i])) { + std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", all[i]); g_app.want_change_type = true; } + ImGui::PopID(); } ImGui::EndMenu(); } @@ -343,6 +358,7 @@ static fn_ui::PanelToggle g_panels[] = { {"Legend", nullptr, &g_app.panel_legend}, {"Inspector", nullptr, &g_app.panel_inspector}, {"Stats", nullptr, &g_app.panel_stats}, + {"Note", nullptr, &g_app.panel_note}, }; static void render() { @@ -372,13 +388,13 @@ static void render() { return; } - // Dockspace host: ocupa el area de trabajo bajo la menubar y permite - // que cualquier ventana (Viewport, Legend, Inspector, Stats, Table) se - // arrastre a un lateral o pestañas dentro de la app. + // Dockspace host: ocupa el area BAJO la toolbar (44 px) para que las + // ventanas dockeadas no queden detras de la barra superior. ImGuiViewport* vp = ImGui::GetMainViewport(); + const float k_toolbar_h = 44.0f; { - ImGui::SetNextWindowPos (vp->WorkPos); - ImGui::SetNextWindowSize(vp->WorkSize); + ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, vp->WorkPos.y + k_toolbar_h)); + ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, vp->WorkSize.y - k_toolbar_h)); ImGui::SetNextWindowViewport(vp->ID); ImGuiWindowFlags hostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | @@ -450,16 +466,38 @@ static void render() { // ---- Mutaciones (add/delete/duplicate/change_type) ---- auto reload_after_mutation = [&]() { graph::GraphLoadStats stats{}; - if (ge::reload_graph(g_input, &g_graph, &stats)) { - ge::entity_index_build(g_input.uri, &g_idx); - ge::views_reset_visibility(g_app); - ge::views_apply_visibility(g_app); - g_graph.update_bounds(); - int restored = ge::layout_store_load(g_graph_hash, g_graph); - if (restored > 0) g_graph.update_bounds(); - g_atlas_bound = false; - g_gpu_dirty = true; + if (!ge::reload_graph(g_input, &g_graph, &stats)) return; + ge::entity_index_build(g_input.uri, &g_idx); + ge::views_reset_visibility(g_app); + ge::views_apply_visibility(g_app); + + // Restablece posiciones guardadas. Los nodos nuevos no tienen + // posicion en el layout_store y caen en (0,0). + int restored = ge::layout_store_load(g_graph_hash, g_graph); + (void)restored; + + // Centro del area visible en world coords (para que los nuevos nodos + // aparezcan donde el usuario esta mirando, no en el origen). + float cx = -g_viewport.cam_x; + float cy = -g_viewport.cam_y; + float spread_r = 80.0f / (g_viewport.zoom > 0.01f ? g_viewport.zoom : 0.01f); + + // Reparte los nodos sin posicion en un anillo poisson alrededor del + // centro visible. Determinista por user_data para que el mismo nodo + // caiga siempre en el mismo sitio entre reloads. + for (int i = 0; i < g_graph.node_count; ++i) { + GraphNode& n = g_graph.nodes[i]; + if (n.x != 0.0f || n.y != 0.0f) continue; + uint64_t h = n.user_data ? n.user_data : (uint64_t)i * 2654435761ull; + float a = (float)((h >> 0) & 0xFFFF) / 65535.0f * 6.2831853f; + float r = spread_r * (0.4f + (float)((h >> 16) & 0xFFFF) / 65535.0f * 0.6f); + n.x = cx + std::cos(a) * r; + n.y = cy + std::sin(a) * r; + n.vx = n.vy = 0.0f; } + g_graph.update_bounds(); + g_atlas_bound = false; + g_gpu_dirty = true; }; if (g_app.want_add_node && g_app.add_buf[0]) { @@ -513,6 +551,61 @@ static void render() { g_app.want_change_type = false; } + // Reset layout: limpia NF_PINNED en todos los nodos. El layout activo se + // reaplica via apply_layout_tick (la toolbar ya lo incrementa). + if (g_app.want_unpin_all) { + for (int i = 0; i < g_graph.node_count; ++i) { + g_graph.nodes[i].flags &= ~NF_PINNED; + g_graph.nodes[i].vx = 0.0f; + g_graph.nodes[i].vy = 0.0f; + } + g_viewport.layout_running = true; + g_app.want_unpin_all = false; + } + + // Note editor — abrir / guardar. + if (g_app.want_open_note && g_app.open_note_target >= 0 + && g_app.open_note_target < g_graph.node_count) { + int n = g_app.open_note_target; + const char* sql_id = ge::entity_index_lookup(g_idx, g_graph.nodes[n].user_data); + if (sql_id) { + std::string md; + ge::entity_get_notes(g_app.input_db_path.c_str(), sql_id, &md); + g_app.note_node = n; + g_app.note_entity_id = sql_id; + const char* lbl = graph::graph_label(&g_graph, g_graph.nodes[n].label_idx); + g_app.note_entity_label = lbl ? lbl : ""; + uint16_t tid = g_graph.nodes[n].type_id; + g_app.note_entity_type = (tid < (uint16_t)g_graph.type_count + && g_graph.types[tid].name) + ? g_graph.types[tid].name : ""; + // Asegura buffer >= max(64KB, contenido + holgura). + size_t need = md.size() + 4096; + if (need < 65536) need = 65536; + g_app.note_buf.assign(need, 0); + std::memcpy(g_app.note_buf.data(), md.data(), md.size()); + g_app.note_dirty = false; + g_app.panel_note = true; + ImGui::SetWindowFocus(TI_FILE_TEXT " Note"); + } + g_app.want_open_note = false; + g_app.open_note_target = -1; + } + + if (g_app.want_save_note && !g_app.note_entity_id.empty()) { + if (ge::entity_set_notes(g_app.input_db_path.c_str(), + g_app.note_entity_id.c_str(), + g_app.note_buf.data())) { + g_app.note_dirty = false; + std::fprintf(stdout, "[graph_explorer] saved note for %s (%zu bytes)\n", + g_app.note_entity_id.c_str(), + std::strlen(g_app.note_buf.data())); + } else { + std::fprintf(stderr, "[graph_explorer] save note failed\n"); + } + g_app.want_save_note = false; + } + // Posiciones iniciales razonables; el usuario puede moverlas y se // persiste via imgui.ini. const float top = vp->WorkPos.y + 44.0f; @@ -531,6 +624,7 @@ static void render() { GraphViewportCallbacks vp_cb{}; vp_cb.on_context_menu = &on_context_menu_cb; + vp_cb.on_double_click = &on_double_click_cb; graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0), vp_cb); render_context_menu(); @@ -572,6 +666,13 @@ static void render() { ImGui::SetNextWindowSize(ImVec2(rw, H - sh), ImGuiCond_FirstUseEver); ge::views_stats(g_app); + // Note editor — al abrirse por primera vez se posiciona como ventana + // centrada. El usuario la puede dockear donde prefiera. + ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.25f, top + 40.0f), + ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(700.0f, 480.0f), ImGuiCond_FirstUseEver); + ge::views_note(g_app); + g_first_render = false; } diff --git a/views.cpp b/views.cpp index b7838c0..454d3df 100644 --- a/views.cpp +++ b/views.cpp @@ -138,6 +138,10 @@ void views_toolbar(AppState& app) { if (button(TI_REFRESH " Reload", ButtonVariant::Subtle)) { app.want_reload = true; } + if (button(TI_LAYOUT_GRID " Reset layout", ButtonVariant::Subtle)) { + app.want_unpin_all = true; + ++app.apply_layout_tick; + } toolbar_separator(); ImGui::Checkbox("GPU layout", &app.use_gpu); @@ -338,6 +342,78 @@ void views_stats(AppState& app) { 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 // ---------------------------------------------------------------------------- diff --git a/views.h b/views.h index 6b310c4..03a5683 100644 --- a/views.h +++ b/views.h @@ -1,6 +1,7 @@ #pragma once #include +#include struct GraphData; struct GraphViewportState; @@ -14,9 +15,11 @@ struct AppState { GraphData* graph = nullptr; GraphViewportState* viewport = nullptr; - // Layout activo - int layout_mode = 0; // 0=force, 1=grid, 2=circular, 3=radial, 4=hierarchical, 5=fixed + // Layout activo — default grid (1) para que los grafos cargados de + // operations.db se distribuyan ordenadamente al abrir. + int layout_mode = 1; // 0=force, 1=grid, 2=circular, 3=radial, 4=hierarchical, 5=fixed int apply_layout_tick = 0; // se incrementa cuando hay que reaplicar layout + bool want_unpin_all = false; // Reset layout: limpia NF_PINNED y reaplica // Force layout — config + GPU toggle float repulsion = 1500.0f; @@ -38,6 +41,7 @@ struct AppState { bool panel_inspector = true; bool panel_stats = true; bool panel_viewport = true; + bool panel_note = false; bool show_filters_modal = false; bool show_open_modal = false; @@ -68,6 +72,17 @@ struct AppState { // Context menu state — popup global identificado por nombre. bool ctx_open_request = false; // se setea en on_context_menu + + // Note editor (panel "Note" abierto con doble click sobre nodo). + int note_node = -1; // node_idx siendo editado + std::string note_entity_id; // sql id resuelto + std::string note_entity_label; // display + std::string note_entity_type; + std::vector note_buf; // editable, NUL-terminated + bool note_dirty = false; + bool want_save_note = false; + bool want_open_note = false; // doble click → cargar y abrir + int open_note_target = -1; // node_idx a abrir }; // Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout). @@ -82,6 +97,10 @@ void views_inspector(AppState& app); // Stats line — counts + fps + energy + selection. void views_stats(AppState& app); +// Note editor — abre con doble click sobre un nodo. Edita la columna `notes` +// (markdown) de la entidad y guarda con un boton. +void views_note(AppState& app); + // Modal Filters — toggles por tipo agrupados en columnas. Devuelve true si // el usuario togglo algo. bool views_filters_modal(AppState& app);