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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user