feat(tahuantinsuyu): fase 25 — production polish (tree search + hotkey SVG + aspect tooltips)

Tres mejoras de usabilidad que aprovechan toda la infraestructura ya
construida. PNG export (#17) lo dejé para una fase futura — agregar
resvg suma ~1MB al binario y el SVG ya cubre el caso profesional.

## #9 — Búsqueda en el tree

Arriba del árbol de groups/contacts/cartas aparece un TextInput con
placeholder "Buscar nombre…". Al apretar Enter aplica el filtro:
case-insensitive substring match sobre group.name, contact.name y
chart.label. Auto-expande recursivamente todos los ancestros que
contienen un match — los resultados quedan visibles sin que el
usuario tenga que abrir chevrons. Escape limpia el filtro.

- tree: TahuantinsuyuTree gana `search_filter: String` y
  `search_input: Entity<TextInput>`. set_search_filter() actualiza +
  auto_expand_matches + refresh.
- group_has_match() / contact_has_match() recursivos chequean si
  algún descendiente matchea.
- append_groups/contacts/charts filtran por substring antes de
  emitir cada row.
- render: nueva barra search arriba con border-bottom.

## #10 — Hotkey [S] = export SVG

El canvas ya tenía botón "⬇ SVG" en el header del wheel. Ahora la
tecla [S] sobre el wheel (con focus) emite el mismo evento
ExportSvgRequested. La línea de hotkeys del footer pasa a:
"[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [S]vg [R]eset".

## #8 — Tooltips sobre líneas de aspecto

- engine: LineSeg gana fields opcionales `from_body: String`,
  `to_body: String`, `orb_deg: f32` (default empty/0.0 para
  back-compat serde). Bridge popula los 6 sites de LineSeg
  construction (natal aspects + 5 cross overlays) vía un script
  Python regex que añadió `from_body: body_symbol(a.X).into()` +
  `to_body: body_symbol(a.Y).into()` + `orb_deg: a.orb_abs_deg() as
  f32` a cada constructor.
- canvas: HoverInfo gana variante Aspect { module_id, from_body,
  to_body, kind, orb_deg, local_x, local_y }. on_hover_check itera
  Aspects layers después de los Bodies (precedencia a planetas) y
  computa distancia punto-segmento con `dist_point_segment` helper —
  threshold 4px para hover. Tooltip muestra "☉ △ ♂  ·  orb 2.3°" con
  prefix de módulo si no es natal.

cargo check verde, 8 tests engine + 1 modules verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 00:05:50 +00:00
parent a539fab15c
commit 6191ce7dee
4 changed files with 313 additions and 7 deletions
@@ -156,6 +156,14 @@ pub struct TahuantinsuyuTree {
/// llamar [`Self::set_city_atlas`] para reemplazar por uno custom
/// cargado desde disco (TSV).
city_atlas: Vec<CityPreset>,
/// Filtro de búsqueda activo. Vacío = sin filtro (jerarquía
/// completa). Cuando hay texto, refresh() solo incluye rows cuyo
/// nombre (group / contact / chart label) contenga el substring
/// case-insensitive, y auto-expande los ancestros para que el
/// match sea visible.
search_filter: String,
/// TextInput para el filtro — vive arriba del tree.
search_input: Entity<TextInput>,
}
/// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
@@ -331,6 +339,23 @@ impl TahuantinsuyuTree {
})
.detach();
let search_input = cx.new(|cx| {
TextInput::new(String::new(), cx)
.with_placeholder(SharedString::from("Buscar nombre…"))
});
cx.subscribe(
&search_input,
|this: &mut Self, _, ev: &TextInputEvent, cx| match ev {
TextInputEvent::Confirmed(value) => {
this.set_search_filter(value.clone(), cx);
}
TextInputEvent::Cancelled => {
this.set_search_filter(String::new(), cx);
}
},
)
.detach();
let mut me = Self {
store,
inner,
@@ -339,6 +364,8 @@ impl TahuantinsuyuTree {
modal: None,
city_picker_open: false,
city_atlas: default_city_presets(),
search_filter: String::new(),
search_input,
};
me.refresh(cx);
me
@@ -360,12 +387,135 @@ impl TahuantinsuyuTree {
self.inner.update(cx, |t, cx| t.set_rows(rows, cx));
}
/// Actualiza el filtro de búsqueda — texto vacío = sin filtro.
/// Cuando hay filtro, expande automáticamente los ancestros que
/// contienen matches para que el usuario vea los resultados sin
/// tener que clickear chevrons.
fn set_search_filter(&mut self, filter: String, cx: &mut Context<Self>) {
self.search_filter = filter.trim().to_lowercase();
if !self.search_filter.is_empty() {
self.auto_expand_matches();
}
self.refresh(cx);
}
/// Pre-expande todos los groups + contacts que contienen al menos
/// un descendiente cuyo nombre matchee el filtro. Hace una pasada
/// recursiva agregando ids al `expanded` set.
fn auto_expand_matches(&mut self) {
fn walk_group(this: &mut TahuantinsuyuTree, group_id: GroupId) -> bool {
let mut any_match = false;
// Sub-groups recursivamente.
if let Ok(children) = this.store.list_groups(Some(group_id)) {
for g in children {
let name_match = g.name.to_lowercase().contains(&this.search_filter);
let child_match = walk_group(this, g.id);
if name_match || child_match {
this.expanded.insert(format!("{}{}", PREFIX_GROUP, g.id));
any_match = true;
}
}
}
// Contacts directos.
if let Ok(contacts) = this.store.list_contacts(Some(group_id)) {
for c in contacts {
let name_match = c.name.to_lowercase().contains(&this.search_filter);
let chart_match = contact_has_matching_chart(this, c.id);
if name_match || chart_match {
this.expanded.insert(format!("{}{}", PREFIX_CONTACT, c.id));
any_match = true;
}
}
}
any_match
}
fn contact_has_matching_chart(this: &TahuantinsuyuTree, contact_id: ContactId) -> bool {
this.store
.list_charts(contact_id)
.map(|charts| {
charts
.iter()
.any(|h| h.label.to_lowercase().contains(&this.search_filter))
})
.unwrap_or(false)
}
// Top-level groups + contacts directos en raíz.
if let Ok(groups) = self.store.list_groups(None) {
for g in groups {
let name_match = g.name.to_lowercase().contains(&self.search_filter);
let child_match = walk_group(self, g.id);
if name_match || child_match {
self.expanded.insert(format!("{}{}", PREFIX_GROUP, g.id));
}
}
}
if let Ok(contacts) = self.store.list_contacts(None) {
for c in contacts {
let name_match = c.name.to_lowercase().contains(&self.search_filter);
let chart_match = contact_has_matching_chart(self, c.id);
if name_match || chart_match {
self.expanded.insert(format!("{}{}", PREFIX_CONTACT, c.id));
}
}
}
}
/// `true` si la jerarquía bajo `group_id` (recursivo) tiene
/// algún descendiente que matchee el filtro de búsqueda.
fn group_has_match(&self, group_id: GroupId) -> bool {
if let Ok(sub) = self.store.list_groups(Some(group_id)) {
for g in &sub {
if g.name.to_lowercase().contains(&self.search_filter) {
return true;
}
if self.group_has_match(g.id) {
return true;
}
}
}
if let Ok(contacts) = self.store.list_contacts(Some(group_id)) {
for c in &contacts {
if c.name.to_lowercase().contains(&self.search_filter) {
return true;
}
if let Ok(charts) = self.store.list_charts(c.id) {
if charts
.iter()
.any(|h| h.label.to_lowercase().contains(&self.search_filter))
{
return true;
}
}
}
}
false
}
/// `true` si el contacto tiene una carta cuyo label matchee.
fn contact_has_match(&self, contact_id: ContactId) -> bool {
if let Ok(charts) = self.store.list_charts(contact_id) {
return charts
.iter()
.any(|h| h.label.to_lowercase().contains(&self.search_filter));
}
false
}
fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
let groups = match self.store.list_groups(parent) {
Ok(v) => v,
Err(_) => return,
};
for g in groups {
// Filtro: incluir si el group matchea por nombre o tiene
// algún descendiente matching.
if !self.search_filter.is_empty() {
let name_match = g.name.to_lowercase().contains(&self.search_filter);
if !name_match && !self.group_has_match(g.id) {
continue;
}
}
let id_str = format!("{}{}", PREFIX_GROUP, g.id);
let expanded = self.expanded.contains(&id_str);
out.push(TreeRow {
@@ -389,6 +539,12 @@ impl TahuantinsuyuTree {
Err(_) => return,
};
for c in contacts {
if !self.search_filter.is_empty() {
let name_match = c.name.to_lowercase().contains(&self.search_filter);
if !name_match && !self.contact_has_match(c.id) {
continue;
}
}
let id_str = format!("{}{}", PREFIX_CONTACT, c.id);
let expanded = self.expanded.contains(&id_str);
out.push(TreeRow {
@@ -411,6 +567,11 @@ impl TahuantinsuyuTree {
Err(_) => return,
};
for h in charts {
if !self.search_filter.is_empty()
&& !h.label.to_lowercase().contains(&self.search_filter)
{
continue;
}
let id_str = format!("{}{}", PREFIX_CHART, h.id);
out.push(TreeRow {
id: RowId::new(id_str),
@@ -1049,6 +1210,13 @@ const MENU_WIDTH: f32 = 220.0;
impl Render for TahuantinsuyuTree {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let search_bar = div()
.px(px(6.0))
.py(px(4.0))
.border_b_1()
.border_color(theme.border)
.child(self.search_input.clone());
let mut root = div()
.id("tahuantinsuyu-tree-root")
.size_full()
@@ -1056,7 +1224,8 @@ impl Render for TahuantinsuyuTree {
.bg(theme.bg_panel.clone())
.flex()
.flex_col()
.child(self.inner.clone());
.child(search_bar)
.child(div().flex_grow().min_h(px(0.0)).child(self.inner.clone()));
if let Some(menu) = self.menu.clone() {
root = root.child(self.render_menu(&theme, menu, cx));