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
+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();