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:
@@ -120,9 +120,8 @@ pub struct CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Info del elemento bajo el cursor — usado por el render para mostrar
|
/// Info del elemento bajo el cursor — usado por el render para mostrar
|
||||||
/// un tooltip flotante con detalles. Cubre body glyphs (módulo +
|
/// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
|
||||||
/// símbolo + grado + casa + retro + dignidad) y cusps de casa (número
|
/// y líneas de aspectos.
|
||||||
/// de la casa + grado del cusp + signo).
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum HoverInfo {
|
pub enum HoverInfo {
|
||||||
Body {
|
Body {
|
||||||
@@ -142,6 +141,18 @@ pub enum HoverInfo {
|
|||||||
local_x: f32,
|
local_x: f32,
|
||||||
local_y: f32,
|
local_y: f32,
|
||||||
},
|
},
|
||||||
|
/// Hover sobre una línea de aspecto. `from_body`/`to_body` y `kind`
|
||||||
|
/// vienen de la LineSeg; `orb_deg` también. Los coords son el
|
||||||
|
/// punto medio del segmento donde se muestra el tooltip.
|
||||||
|
Aspect {
|
||||||
|
module_id: String,
|
||||||
|
from_body: String,
|
||||||
|
to_body: String,
|
||||||
|
kind: String,
|
||||||
|
orb_deg: f32,
|
||||||
|
local_x: f32,
|
||||||
|
local_y: f32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HoverInfo {
|
impl HoverInfo {
|
||||||
@@ -149,6 +160,7 @@ impl HoverInfo {
|
|||||||
match self {
|
match self {
|
||||||
HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
|
HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
|
||||||
HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
|
HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
|
||||||
|
HoverInfo::Aspect { local_x, local_y, .. } => (*local_x, *local_y),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +170,13 @@ impl HoverInfo {
|
|||||||
module_id, symbol, ..
|
module_id, symbol, ..
|
||||||
} => format!("body:{}:{}", module_id, symbol),
|
} => format!("body:{}:{}", module_id, symbol),
|
||||||
HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
|
HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
|
||||||
|
HoverInfo::Aspect {
|
||||||
|
module_id,
|
||||||
|
from_body,
|
||||||
|
to_body,
|
||||||
|
kind,
|
||||||
|
..
|
||||||
|
} => format!("aspect:{}:{}-{}-{}", module_id, from_body, kind, to_body),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +385,52 @@ impl AstrologyCanvas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) House cusps — solo si el mouse está cerca del anillo de
|
// 2) Aspect lines (segundo: las líneas son más "frágiles" que
|
||||||
|
// los planetas; si un body matcheó arriba ya tomó precedencia).
|
||||||
|
// Computa distancia punto-segmento del mouse al line.
|
||||||
|
if best.is_none() {
|
||||||
|
for layer in &render.layers {
|
||||||
|
if !matches!(layer.kind, LayerKind::Aspects) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
|
||||||
|
if let Geometry::Lines(segs) = &layer.geometry {
|
||||||
|
for seg in segs {
|
||||||
|
if seg.from_body.is_empty() || seg.to_body.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, r_from);
|
||||||
|
let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, r_to);
|
||||||
|
let px_a = cx_px + ax;
|
||||||
|
let py_a = cy_px + ay;
|
||||||
|
let px_b = cx_px + bx;
|
||||||
|
let py_b = cy_px + by;
|
||||||
|
let dist = dist_point_segment(mx, my, px_a, py_a, px_b, py_b);
|
||||||
|
if dist > 4.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
|
||||||
|
let mid_x = (px_a + px_b) / 2.0;
|
||||||
|
let mid_y = (py_a + py_b) / 2.0;
|
||||||
|
best = Some((
|
||||||
|
dist,
|
||||||
|
HoverInfo::Aspect {
|
||||||
|
module_id: layer.module_id.clone(),
|
||||||
|
from_body: seg.from_body.clone(),
|
||||||
|
to_body: seg.to_body.clone(),
|
||||||
|
kind: seg.kind.clone(),
|
||||||
|
orb_deg: seg.orb_deg,
|
||||||
|
local_x: mid_x - ox,
|
||||||
|
local_y: mid_y - oy,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) House cusps — solo si el mouse está cerca del anillo de
|
||||||
// casas (radio entre houses_inner y houses_outer + margen) y
|
// casas (radio entre houses_inner y houses_outer + margen) y
|
||||||
// ningún body ganó. Las cusps son líneas radiales — la
|
// ningún body ganó. Las cusps son líneas radiales — la
|
||||||
// distancia angular al cusp más cercano determina el hit.
|
// distancia angular al cusp más cercano determina el hit.
|
||||||
@@ -464,6 +528,10 @@ impl AstrologyCanvas {
|
|||||||
self.reset_time_offset(cx);
|
self.reset_time_offset(cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
"s" | "S" => {
|
||||||
|
cx.emit(CanvasEvent::ExportSvgRequested);
|
||||||
|
return;
|
||||||
|
}
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
self.toggle_layer(kind, cx);
|
self.toggle_layer(kind, cx);
|
||||||
@@ -857,6 +925,26 @@ fn render_wheel(
|
|||||||
house_number, sign_name, deg_in_sign
|
house_number, sign_name, deg_in_sign
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
HoverInfo::Aspect {
|
||||||
|
module_id,
|
||||||
|
from_body,
|
||||||
|
to_body,
|
||||||
|
kind,
|
||||||
|
orb_deg,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let mut t = format!(
|
||||||
|
"{} {} {} · orb {:.1}°",
|
||||||
|
planet_unicode(from_body),
|
||||||
|
aspect_unicode(kind),
|
||||||
|
planet_unicode(to_body),
|
||||||
|
orb_deg
|
||||||
|
);
|
||||||
|
if module_id != "natal" {
|
||||||
|
t.push_str(&format!(" · {}", module_id));
|
||||||
|
}
|
||||||
|
t
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let (lx, ly) = hov.local();
|
let (lx, ly) = hov.local();
|
||||||
let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
|
let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
|
||||||
@@ -981,7 +1069,7 @@ fn render_wheel(
|
|||||||
div()
|
div()
|
||||||
.text_size(px(10.0))
|
.text_size(px(10.0))
|
||||||
.text_color(theme.fg_disabled)
|
.text_color(theme.fg_disabled)
|
||||||
.child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [R]eset"),
|
.child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [S]vg [R]eset"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Badges de overlays activos. Cada uno se pinta como pill con
|
// Badges de overlays activos. Cada uno se pinta como pill con
|
||||||
@@ -1626,6 +1714,26 @@ fn paint_cross_aspect_line(
|
|||||||
// Helpers
|
// Helpers
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Distancia mínima entre un punto y un segmento de recta. Usado por
|
||||||
|
/// hover_check para detectar proximity a líneas de aspectos.
|
||||||
|
fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
|
||||||
|
let dx = bx - ax;
|
||||||
|
let dy = by - ay;
|
||||||
|
let len_sq = dx * dx + dy * dy;
|
||||||
|
if len_sq < f32::EPSILON {
|
||||||
|
// Segmento degenerado → distancia al punto a.
|
||||||
|
let pdx = px - ax;
|
||||||
|
let pdy = py - ay;
|
||||||
|
return (pdx * pdx + pdy * pdy).sqrt();
|
||||||
|
}
|
||||||
|
let t = (((px - ax) * dx + (py - ay) * dy) / len_sq).clamp(0.0, 1.0);
|
||||||
|
let proj_x = ax + t * dx;
|
||||||
|
let proj_y = ay + t * dy;
|
||||||
|
let dx2 = px - proj_x;
|
||||||
|
let dy2 = py - proj_y;
|
||||||
|
(dx2 * dx2 + dy2 * dy2).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
fn polar_to_screen(
|
fn polar_to_screen(
|
||||||
longitude_deg: f32,
|
longitude_deg: f32,
|
||||||
ascendant_deg: f32,
|
ascendant_deg: f32,
|
||||||
|
|||||||
@@ -416,6 +416,9 @@ fn build_transit_overlay(
|
|||||||
to_deg: transit_p.longitude.longitude_deg() as f32,
|
to_deg: transit_p.longitude.longitude_deg() as f32,
|
||||||
kind: aspect_kind_id(a.kind).into(),
|
kind: aspect_kind_id(a.kind).into(),
|
||||||
opacity: opacity * 0.75,
|
opacity: opacity * 0.75,
|
||||||
|
from_body: body_symbol(a.person_a_body).into(),
|
||||||
|
to_body: body_symbol(a.person_b_body).into(),
|
||||||
|
orb_deg: a.orb_abs_deg() as f32,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -485,6 +488,9 @@ fn build_progression_overlay(
|
|||||||
to_deg: prog_p.longitude.longitude_deg() as f32,
|
to_deg: prog_p.longitude.longitude_deg() as f32,
|
||||||
kind: aspect_kind_id(a.kind).into(),
|
kind: aspect_kind_id(a.kind).into(),
|
||||||
opacity: opacity * 0.7,
|
opacity: opacity * 0.7,
|
||||||
|
from_body: body_symbol(a.person_a_body).into(),
|
||||||
|
to_body: body_symbol(a.person_b_body).into(),
|
||||||
|
orb_deg: a.orb_abs_deg() as f32,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -709,6 +715,9 @@ fn build_solar_arc_overlay(
|
|||||||
to_deg: dir_p.longitude.longitude_deg() as f32,
|
to_deg: dir_p.longitude.longitude_deg() as f32,
|
||||||
kind: aspect_kind_id(a.kind).into(),
|
kind: aspect_kind_id(a.kind).into(),
|
||||||
opacity: opacity * 0.7,
|
opacity: opacity * 0.7,
|
||||||
|
from_body: body_symbol(a.person_a_body).into(),
|
||||||
|
to_body: body_symbol(a.person_b_body).into(),
|
||||||
|
orb_deg: a.orb_abs_deg() as f32,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -773,6 +782,9 @@ fn build_synastry_overlay(
|
|||||||
to_deg: partner_p.longitude.longitude_deg() as f32,
|
to_deg: partner_p.longitude.longitude_deg() as f32,
|
||||||
kind: aspect_kind_id(a.kind).into(),
|
kind: aspect_kind_id(a.kind).into(),
|
||||||
opacity: opacity * 0.85,
|
opacity: opacity * 0.85,
|
||||||
|
from_body: body_symbol(a.person_a_body).into(),
|
||||||
|
to_body: body_symbol(a.person_b_body).into(),
|
||||||
|
orb_deg: a.orb_abs_deg() as f32,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -883,6 +895,9 @@ fn build_planetary_return_overlay(
|
|||||||
to_deg: r_p.longitude.longitude_deg() as f32,
|
to_deg: r_p.longitude.longitude_deg() as f32,
|
||||||
kind: aspect_kind_id(a.kind).into(),
|
kind: aspect_kind_id(a.kind).into(),
|
||||||
opacity: opacity * 0.8,
|
opacity: opacity * 0.8,
|
||||||
|
from_body: body_symbol(a.person_a_body).into(),
|
||||||
|
to_body: body_symbol(a.person_b_body).into(),
|
||||||
|
orb_deg: a.orb_abs_deg() as f32,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1014,6 +1029,9 @@ fn build_render_model(
|
|||||||
to_deg: pb.longitude.longitude_deg() as f32,
|
to_deg: pb.longitude.longitude_deg() as f32,
|
||||||
kind: aspect_kind_id(a.kind).into(),
|
kind: aspect_kind_id(a.kind).into(),
|
||||||
opacity,
|
opacity,
|
||||||
|
from_body: body_symbol(a.a).into(),
|
||||||
|
to_body: body_symbol(a.b).into(),
|
||||||
|
orb_deg: a.orb_abs_deg() as f32,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ pub enum Geometry {
|
|||||||
Points(Vec<PointMark>),
|
Points(Vec<PointMark>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct LineSeg {
|
pub struct LineSeg {
|
||||||
/// Grados zodiacales del extremo "a".
|
/// Grados zodiacales del extremo "a".
|
||||||
pub from_deg: f32,
|
pub from_deg: f32,
|
||||||
@@ -176,6 +176,17 @@ pub struct LineSeg {
|
|||||||
/// resuelve a color.
|
/// resuelve a color.
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub opacity: f32,
|
pub opacity: f32,
|
||||||
|
/// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
|
||||||
|
/// (natal × natal, cross con overlays). Vacío en `Default::default`
|
||||||
|
/// para serde back-compat.
|
||||||
|
#[serde(default)]
|
||||||
|
pub from_body: String,
|
||||||
|
/// Cuerpo en el extremo "b".
|
||||||
|
#[serde(default)]
|
||||||
|
pub to_body: String,
|
||||||
|
/// Orb absoluto en grados (para tooltips).
|
||||||
|
#[serde(default)]
|
||||||
|
pub orb_deg: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ pub struct TahuantinsuyuTree {
|
|||||||
/// llamar [`Self::set_city_atlas`] para reemplazar por uno custom
|
/// llamar [`Self::set_city_atlas`] para reemplazar por uno custom
|
||||||
/// cargado desde disco (TSV).
|
/// cargado desde disco (TSV).
|
||||||
city_atlas: Vec<CityPreset>,
|
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
|
/// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
|
||||||
@@ -331,6 +339,23 @@ impl TahuantinsuyuTree {
|
|||||||
})
|
})
|
||||||
.detach();
|
.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 {
|
let mut me = Self {
|
||||||
store,
|
store,
|
||||||
inner,
|
inner,
|
||||||
@@ -339,6 +364,8 @@ impl TahuantinsuyuTree {
|
|||||||
modal: None,
|
modal: None,
|
||||||
city_picker_open: false,
|
city_picker_open: false,
|
||||||
city_atlas: default_city_presets(),
|
city_atlas: default_city_presets(),
|
||||||
|
search_filter: String::new(),
|
||||||
|
search_input,
|
||||||
};
|
};
|
||||||
me.refresh(cx);
|
me.refresh(cx);
|
||||||
me
|
me
|
||||||
@@ -360,12 +387,135 @@ impl TahuantinsuyuTree {
|
|||||||
self.inner.update(cx, |t, cx| t.set_rows(rows, cx));
|
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>) {
|
fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
|
||||||
let groups = match self.store.list_groups(parent) {
|
let groups = match self.store.list_groups(parent) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
for g in groups {
|
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 id_str = format!("{}{}", PREFIX_GROUP, g.id);
|
||||||
let expanded = self.expanded.contains(&id_str);
|
let expanded = self.expanded.contains(&id_str);
|
||||||
out.push(TreeRow {
|
out.push(TreeRow {
|
||||||
@@ -389,6 +539,12 @@ impl TahuantinsuyuTree {
|
|||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
for c in contacts {
|
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 id_str = format!("{}{}", PREFIX_CONTACT, c.id);
|
||||||
let expanded = self.expanded.contains(&id_str);
|
let expanded = self.expanded.contains(&id_str);
|
||||||
out.push(TreeRow {
|
out.push(TreeRow {
|
||||||
@@ -411,6 +567,11 @@ impl TahuantinsuyuTree {
|
|||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
for h in charts {
|
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);
|
let id_str = format!("{}{}", PREFIX_CHART, h.id);
|
||||||
out.push(TreeRow {
|
out.push(TreeRow {
|
||||||
id: RowId::new(id_str),
|
id: RowId::new(id_str),
|
||||||
@@ -1049,6 +1210,13 @@ const MENU_WIDTH: f32 = 220.0;
|
|||||||
impl Render for TahuantinsuyuTree {
|
impl Render for TahuantinsuyuTree {
|
||||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let theme = Theme::global(cx).clone();
|
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()
|
let mut root = div()
|
||||||
.id("tahuantinsuyu-tree-root")
|
.id("tahuantinsuyu-tree-root")
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -1056,7 +1224,8 @@ impl Render for TahuantinsuyuTree {
|
|||||||
.bg(theme.bg_panel.clone())
|
.bg(theme.bg_panel.clone())
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.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() {
|
if let Some(menu) = self.menu.clone() {
|
||||||
root = root.child(self.render_menu(&theme, menu, cx));
|
root = root.child(self.render_menu(&theme, menu, cx));
|
||||||
|
|||||||
Reference in New Issue
Block a user