feat(filter): tag chips + FTS5 search en toolbar (issue 0009)

- entity_ops: entity_search_fts (bm25, prefix tokens) + entity_list_by_tags (AND).
- AppState: filter_query_buf, filter_tags, filter_mode (highlight/hide), hits cache.
- views_filter_apply: combina tipos visibles + match-set (FTS query AND tags),
  highlight = color_override con alpha=0x40, hide = clear NF_VISIBLE.
- toolbar: input search + dropdown (max 20, click centra y selecciona),
  chips de tags con boton X, input para anadir tag, combo Highlight/Hide,
  Clear filter.
- Inspector: right-click sobre tag chip lo anade al filtro.
- main.cpp: reapply en cada frame si filter_dirty; cam_x/y al focus_target.
This commit is contained in:
2026-05-01 00:23:26 +02:00
parent e49cef5cb3
commit 54aba71bf5
6 changed files with 440 additions and 14 deletions
+120
View File
@@ -652,6 +652,126 @@ bool entity_list_distinct_tags(const char* db_path,
return true;
}
// ----------------------------------------------------------------------------
// FTS5 search (issue 0009)
// ----------------------------------------------------------------------------
namespace {
// Sanitiza la query del usuario para FTS5: drop de chars que rompen el
// parser, tokeniza por whitespace y envuelve cada token en comillas dobles
// con prefijo `*`. Asi `"john doe"` -> `"john"* "doe"*`. La presencia de
// varios tokens equivale a AND implicito en FTS5.
std::string fts5_sanitize(const char* q) {
std::string out;
if (!q) return out;
const char* p = q;
while (*p) {
while (*p && std::isspace((unsigned char)*p)) ++p;
if (!*p) break;
std::string tok;
while (*p && !std::isspace((unsigned char)*p)) {
char c = *p++;
// Drop chars con significado en FTS5.
if (c == '"' || c == '*' || c == '(' || c == ')' || c == ':' ||
c == '^' || c == '\'' || c == '!' || c == '+') continue;
tok += c;
}
if (tok.empty()) continue;
if (!out.empty()) out += ' ';
out += '"';
out += tok;
out += "\"*";
}
return out;
}
} // namespace
bool entity_search_fts(const char* db_path, const char* query, int limit,
std::vector<EntityHit>* out)
{
if (!db_path || !out) return false;
out->clear();
if (limit < 1) limit = 1;
if (limit > 200) limit = 200;
std::string match = fts5_sanitize(query);
if (match.empty()) return true;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
// entities_fts.id y entities.id colisionan en JOIN — calificamos siempre.
const char* sql =
"SELECT e.id, e.name, e.type_ref, bm25(entities_fts) AS r "
"FROM entities_fts JOIN entities AS e ON e.rowid = entities_fts.rowid "
"WHERE entities_fts MATCH ?1 ORDER BY r ASC LIMIT ?2";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
sqlite3_bind_text(st, 1, match.c_str(), (int)match.size(), SQLITE_TRANSIENT);
sqlite3_bind_int (st, 2, limit);
while (sqlite3_step(st) == SQLITE_ROW) {
EntityHit h;
const unsigned char* id = sqlite3_column_text(st, 0);
const unsigned char* name = sqlite3_column_text(st, 1);
const unsigned char* tref = sqlite3_column_text(st, 2);
h.id = id ? (const char*)id : "";
h.name = name ? (const char*)name : "";
h.type_ref = tref ? (const char*)tref : "";
h.rank = sqlite3_column_double(st, 3);
out->push_back(std::move(h));
}
sqlite3_finalize(st);
sqlite3_close(db);
return true;
}
bool entity_list_by_tags(const char* db_path,
const std::vector<std::string>& tags,
std::vector<std::string>* out_ids)
{
if (!db_path || !out_ids) return false;
out_ids->clear();
if (tags.empty()) return true;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
// Una clausula EXISTS por tag, todas en AND.
std::string sql = "SELECT id FROM entities WHERE ";
for (size_t i = 0; i < tags.size(); ++i) {
if (i) sql += " AND ";
sql += "EXISTS(SELECT 1 FROM json_each(entities.tags) WHERE json_each.value = ?)";
}
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &st, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
for (size_t i = 0; i < tags.size(); ++i) {
sqlite3_bind_text(st, (int)(i + 1), tags[i].c_str(),
(int)tags[i].size(), SQLITE_TRANSIENT);
}
while (sqlite3_step(st) == SQLITE_ROW) {
const unsigned char* p = sqlite3_column_text(st, 0);
if (p && *p) out_ids->emplace_back((const char*)p);
}
sqlite3_finalize(st);
sqlite3_close(db);
return true;
}
// ----------------------------------------------------------------------------
// Index user_data -> sql id
// ----------------------------------------------------------------------------
+23
View File
@@ -99,6 +99,29 @@ bool entity_update(const char* db_path, const EntityRecord& rec);
bool entity_list_distinct_tags(const char* db_path,
std::vector<std::string>* out);
// Resultado de busqueda FTS5 sobre entities_fts.
struct EntityHit {
std::string id;
std::string name;
std::string type_ref;
double rank = 0.0; // bm25 (mas bajo = mejor)
};
// Busca en entities_fts (columnas: id, name, description, tags, domain).
// Tokeniza `query` por whitespace, descarta caracteres especiales FTS5 y
// aplica busqueda por prefijo a cada token (token*). Limit clampeado en [1,200].
// Devuelve hits ordenados por rank ASC. Si query queda vacio tras sanear,
// devuelve true con out->empty(). Si la BD no tiene la tabla entities_fts
// (operations.db antiguos), devuelve false.
bool entity_search_fts(const char* db_path, const char* query, int limit,
std::vector<EntityHit>* out);
// Devuelve los ids de entidades cuyo array `tags` contiene TODOS los tags
// pasados (AND). Si `tags` vacio, out queda vacio y retorna true.
bool entity_list_by_tags(const char* db_path,
const std::vector<std::string>& tags,
std::vector<std::string>* out_ids);
// 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 {
@@ -1,9 +1,10 @@
---
id: 0009
title: Filtro por tag + busqueda FTS5 en toolbar
status: pending
status: completed
priority: medium
created: 2026-04-30
completed: 2026-05-01
depends_on: [0008]
---
+13
View File
@@ -552,6 +552,19 @@ static void render() {
std::fprintf(stdout, "[graph_explorer] saved %d node positions\n", n);
g_app.want_save_layout = false;
}
// Filtro FTS5/tags (issue 0009) — reaplica si el toolbar marco dirty.
if (g_app.filter_dirty) {
ge::views_filter_apply(g_app);
}
// Centrado del nodo seleccionado desde el dropdown.
if (g_app.filter_focus_target >= 0
&& g_app.filter_focus_target < g_graph.node_count) {
const GraphNode& n = g_graph.nodes[g_app.filter_focus_target];
g_viewport.cam_x = -n.x;
g_viewport.cam_y = -n.y;
g_app.filter_focus_target = -1;
}
if (g_app.want_open_file) {
g_input_path = g_app.open_buf;
g_app.want_open_file = false;
+251 -13
View File
@@ -23,6 +23,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unordered_set>
namespace ge {
@@ -65,20 +66,143 @@ void views_reset_visibility(AppState& app) {
app.rel_type_visible_n = nr;
}
void views_apply_visibility(AppState& app) {
if (!app.graph) return;
GraphData& g = *app.graph;
for (int i = 0; i < g.node_count; ++i) {
uint16_t t = g.nodes[i].type_id;
bool vis = (t < (uint16_t)app.type_visible_n) ? app.type_visible[t] : true;
if (vis) g.nodes[i].flags |= NF_VISIBLE;
else g.nodes[i].flags &= ~NF_VISIBLE;
// 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;
// Si los endpoints estan ocultos, la arista tambien.
bool src_vis = (e.source < (uint32_t)g.node_count) &&
(g.nodes[e.source].flags & NF_VISIBLE);
bool tgt_vis = (e.target < (uint32_t)g.node_count) &&
@@ -87,6 +211,8 @@ void views_apply_visibility(AppState& app) {
if (vis) g.edges[i].flags |= EF_VISIBLE;
else g.edges[i].flags &= ~EF_VISIBLE;
}
app.filter_dirty = false;
}
// ----------------------------------------------------------------------------
@@ -187,6 +313,112 @@ void views_toolbar(AppState& app) {
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();
ImGui::TextUnformatted("Layout:");
ImGui::SameLine();
ImGui::SetNextItemWidth(140);
@@ -788,16 +1020,22 @@ void views_inspector(AppState& app) {
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);
ImGui::PopStyleColor(3);
ImGui::PopID();
removed = true;
any_change = true;
--i;
continue;
} 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();
+31
View File
@@ -136,6 +136,18 @@ struct AppState {
// Caches refrescadas tras cargar grafo o tras Save.
std::vector<std::string> insp_tag_suggestions;
std::vector<std::string> insp_type_options;
// ---- Filtros y busqueda FTS5 (issue 0009) ------------------------------
// Modos: 0 = highlight (no-match dimmed), 1 = hide (no-match invisible).
enum FilterMode { FM_HIGHLIGHT = 0, FM_HIDE = 1 };
int filter_mode = FM_HIGHLIGHT;
char filter_query_buf[128] = {};
std::vector<std::string> filter_tags; // chips activos
std::vector<EntityHit> filter_hits; // dropdown FTS (max 20)
bool filter_dropdown_open = false;
bool filter_dirty = false; // pide reapply
int filter_focus_target = -1; // node_idx a centrar
char filter_tag_input[64] = {}; // input de chip nuevo
};
// Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout).
@@ -193,4 +205,23 @@ EntityRecord views_inspector_build_record(const AppState& app);
// al cambiar de proyecto.
void views_inspector_clear_draft(AppState& app);
// ---- Filter helpers (issue 0009) -----------------------------------------
// True si el filtro tiene query no vacia o al menos un tag activo.
bool views_filter_active(const AppState& app);
// Anade un tag como chip si no existe ya. Marca filter_dirty = true.
void views_filter_add_tag(AppState& app, const char* tag);
// Limpia query + tags y marca filter_dirty.
void views_filter_clear(AppState& app);
// Reaplica el filtro al grafo: recompute la mascara de visibilidad/alpha
// segun filter_query, filter_tags, filter_mode. Llama a la BD si hace
// falta (FTS + tags). Resetea color_override a 0 en los nodos que pasan
// el filtro y aplica un alpha bajo en los que no (modo highlight) o limpia
// NF_VISIBLE (modo hide). Tambien refresca filter_hits para el dropdown
// con max 20 resultados ordenados por rank.
void views_filter_apply(AppState& app);
} // namespace ge