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:
+120
@@ -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
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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]
|
||||
---
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user