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