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