feat(tahuantinsuyu): scaffolding del estudio astrológico (10 crates + ventana 3-panes)

Módulo nuevo `modules/tahuantinsuyu/` con 9 crates reusables + app
`apps/tahuantinsuyu` ejecutable que abre la ventana del explorador y
coordina los widgets:

- tahuantinsuyu-card: Card Brahman + spawn_sidecar (flows
  chart-request/chart-result).
- tahuantinsuyu-model: tipos agnósticos (Group/Contact/Chart,
  StoredBirthData, StoredChartConfig, ChartKind, TreeSelection).
- tahuantinsuyu-store: persistencia SQLite (rusqlite) con migración v1,
  CRUD por entidad y descenso recursivo `charts_under_group`.
- tahuantinsuyu-engine: bridge agnóstico al canvas vía `RenderModel`
  (Layer/Glyph/Geometry). Feature `eternal-bridge` (off por default)
  reservada para enchufar eternal-astrology desde ~/eternal.
- tahuantinsuyu-modules: registry de módulos pluggables (Module trait
  + Control schema) con `NatalModule` placeholder.
- tahuantinsuyu-theme: AstroPalette (elementos / modos / planetas /
  aspectos) con variantes dark + light sobre yahweh-theme.
- tahuantinsuyu-canvas: widget GPUI con CanvasState (Empty / Wheel /
  Thumbnails). Render placeholder hasta cablear la rueda real.
- tahuantinsuyu-tree: explorador izquierdo sobre yahweh-widget-tree,
  prefijos g:/c:/h: para Group/Contact/Chart.
- tahuantinsuyu-panel: control panel inferior que lee Controls de los
  módulos del registry y los pinta.
- apps/tahuantinsuyu: binario `tahuantinsuyu` (launch_app-style) con
  Shell coordinador (tree↔canvas↔panel), DB en $XDG_DATA_HOME.

Workspace Cargo.toml actualizado con los 10 miembros. `cargo check`
verde, tests unitarios verdes (model/store/engine/modules/theme/card).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-16 01:06:03 +00:00
parent e8f97b50cb
commit c48638fe87
23 changed files with 3256 additions and 0 deletions
@@ -0,0 +1,192 @@
//! `tahuantinsuyu-tree` — explorador jerárquico Groups → Contacts → Charts.
//!
//! Envuelve [`yahweh_widget_tree::TreeView`] con la lógica de dominio
//! propia de Tahuantinsuyu. Los `RowId` codifican el tipo del item con
//! prefijo:
//!
//! - `g:<ulid>` → Group
//! - `c:<ulid>` → Contact
//! - `h:<ulid>` → Chart
//!
//! El host (la app) se suscribe a [`TreeEvent`] y traduce a `AppEvent`
//! del bus de yahweh para que el canvas/panel reaccionen.
//!
//! Esta fase 1 trae el wrapper + el armado de filas; el CRUD UX
//! (drag-to-nest, rename inline, menú contextual) llega con la fase 2.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::HashSet;
use gpui::{Context, Entity, EventEmitter, IntoElement, Render, Window, prelude::*};
use tahuantinsuyu_model::{ContactId, GroupId, TreeSelection};
use tahuantinsuyu_store::Store;
use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, TreeView};
const PREFIX_GROUP: &str = "g:";
const PREFIX_CONTACT: &str = "c:";
const PREFIX_CHART: &str = "h:";
// =====================================================================
// Eventos públicos
// =====================================================================
#[derive(Clone, Debug)]
pub enum TreeEvent {
/// El usuario activó (single click) un item.
Selected(TreeSelection),
/// El usuario abrió (doble click) un item — la app decide qué hacer
/// (en general, abrir la carta en el canvas).
Opened(TreeSelection),
}
// =====================================================================
// Widget
// =====================================================================
pub struct TahuantinsuyuTree {
store: Store,
inner: Entity<TreeView>,
expanded: HashSet<String>,
}
impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
impl TahuantinsuyuTree {
pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
let inner = cx.new(|cx| TreeView::new("tahuantinsuyu-tree", cx));
cx.subscribe(&inner, |this: &mut Self, _, ev, cx| {
this.on_inner(ev, cx);
})
.detach();
let mut me = Self {
store,
inner,
expanded: HashSet::new(),
};
me.refresh(cx);
me
}
/// Re-lee la jerarquía desde la store y empuja al TreeView. Llamar
/// después de crear/borrar items.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
let mut rows = Vec::new();
self.append_groups(None, 0, &mut rows);
self.append_contacts(None, 0, &mut rows);
self.inner
.update(cx, |t, cx| t.set_rows(rows, cx));
}
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 {
let id_str = format!("{}{}", PREFIX_GROUP, g.id);
let expanded = self.expanded.contains(&id_str);
out.push(TreeRow {
id: RowId::new(id_str.clone()),
label: g.name.clone(),
depth,
kind: RowKind::Branch,
expanded,
icon: Some("📁".into()),
});
if expanded {
self.append_groups(Some(g.id), depth + 1, out);
self.append_contacts(Some(g.id), depth + 1, out);
}
}
}
fn append_contacts(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
let contacts = match self.store.list_contacts(parent) {
Ok(v) => v,
Err(_) => return,
};
for c in contacts {
let id_str = format!("{}{}", PREFIX_CONTACT, c.id);
let expanded = self.expanded.contains(&id_str);
out.push(TreeRow {
id: RowId::new(id_str.clone()),
label: c.name.clone(),
depth,
kind: RowKind::Branch,
expanded,
icon: Some("🜨".into()),
});
if expanded {
self.append_charts(c.id, depth + 1, out);
}
}
}
fn append_charts(&self, contact: ContactId, depth: u32, out: &mut Vec<TreeRow>) {
let charts = match self.store.list_charts(contact) {
Ok(v) => v,
Err(_) => return,
};
for h in charts {
let id_str = format!("{}{}", PREFIX_CHART, h.id);
out.push(TreeRow {
id: RowId::new(id_str),
label: h.label.clone(),
depth,
kind: RowKind::Leaf,
expanded: false,
icon: Some("".into()),
});
}
}
fn on_inner(&mut self, ev: &InnerTreeEvent, cx: &mut Context<Self>) {
match ev {
InnerTreeEvent::ChevronToggled(id) => {
let s = id.as_str().to_string();
if !self.expanded.remove(&s) {
self.expanded.insert(s);
}
self.refresh(cx);
}
InnerTreeEvent::RowClicked(id) => {
if let Some(sel) = parse_row(id) {
cx.emit(TreeEvent::Selected(sel));
}
}
InnerTreeEvent::RowDoubleClicked(id) => {
if let Some(sel) = parse_row(id) {
cx.emit(TreeEvent::Opened(sel));
}
}
InnerTreeEvent::ContextMenuRequested { .. } => {
// Fase 2: menú contextual para crear/renombrar/borrar.
}
InnerTreeEvent::ActiveChanged(_) => {}
}
}
}
fn parse_row(id: &RowId) -> Option<TreeSelection> {
let s = id.as_str();
if let Some(rest) = s.strip_prefix(PREFIX_GROUP) {
return rest.parse().ok().map(TreeSelection::Group);
}
if let Some(rest) = s.strip_prefix(PREFIX_CONTACT) {
return rest.parse().ok().map(TreeSelection::Contact);
}
if let Some(rest) = s.strip_prefix(PREFIX_CHART) {
return rest.parse().ok().map(TreeSelection::Chart);
}
None
}
impl Render for TahuantinsuyuTree {
fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.inner.clone()
}
}