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
+88
View File
@@ -0,0 +1,88 @@
//! Tahuantinsuyu — binario standalone.
//!
//! Boot:
//! 1. `tahuantinsuyu_card::spawn_sidecar()` se presenta al Init brahman
//! (fire-and-forget; si no hay Init, la app sigue standalone).
//! 2. Abre la DB SQLite en `$XDG_DATA_HOME/tahuantinsuyu/charts.db`
//! (fallback a `~/.local/share/tahuantinsuyu/charts.db`).
//! 3. Levanta GPUI con [`yahweh_theme::Theme::install_default`].
//! 4. Compone el shell: [`Shell`] dueño del tree (izq), canvas (centro)
//! y panel (abajo). Cablea las suscripciones cross-widget.
//!
//! ## Layout
//!
//! ```text
//! ┌───────────┬────────────────────────────────────────┐
//! │ │ │
//! │ tree │ canvas │
//! │ (groups, │ (rueda / thumbnails) │
//! │ contacts,│ │
//! │ charts) │ │
//! │ │ │
//! ├───────────┴────────────────────────────────────────┤
//! │ control panel (módulos) │
//! └─────────────────────────────────────────────────────┘
//! ```
mod shell;
use std::path::PathBuf;
use gpui::{
App, AppContext, Application, Bounds, SharedString, TitlebarOptions, WindowBounds,
WindowOptions, px, size,
};
use tahuantinsuyu_store::Store;
use yahweh_theme::Theme;
use crate::shell::Shell;
const DB_FILENAME: &str = "charts.db";
const APP_TITLE: &str = "Tahuantinsuyu";
fn main() {
// Sidecar brahman primero — si el Init está corriendo, nos presentamos.
tahuantinsuyu_card::spawn_sidecar();
// DB en directorio de datos del usuario.
let db_path = resolve_db_path();
let store = match Store::open(&db_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"[tahuantinsuyu] no se pudo abrir la DB en {:?}: {} — usando memoria",
db_path, e
);
Store::in_memory().expect("in-memory store")
}
};
Application::new().run(move |cx: &mut App| {
Theme::install_default(cx);
let bounds = Bounds::centered(None, size(px(1400.0), px(900.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::from(APP_TITLE)),
..Default::default()
}),
..Default::default()
},
move |_w, cx| cx.new(|cx| Shell::new(store.clone(), cx)),
)
.expect("open window");
cx.activate(true);
});
}
fn resolve_db_path() -> PathBuf {
if let Some(dirs) = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu") {
let dir = dirs.data_dir().to_path_buf();
let _ = std::fs::create_dir_all(&dir);
return dir.join(DB_FILENAME);
}
PathBuf::from(DB_FILENAME)
}
+238
View File
@@ -0,0 +1,238 @@
//! Shell — coordinador de los tres widgets.
//!
//! Es el "director de orquesta": dueño del tree, del canvas y del panel,
//! reenvía eventos entre ellos y aplica las mutaciones en la store.
//!
//! Flujo típico:
//!
//! ```text
//! Tree.Selected(Chart) → Shell → Canvas.set_mode(Wheel)
//! → Panel.set_active_kind(chart.kind)
//!
//! Tree.Selected(Group) → Shell → Canvas.set_mode(Thumbnails{…})
//! → Panel.set_active_kind(None)
//!
//! Panel.ModuleToggled → Shell → Store.upsert_module_state
//! → Canvas.toggle_module
//! ```
//!
//! Fase 1: las suscripciones están cableadas pero los handlers son
//! mínimos (logging + transición de modo). La pipeline real de cómputo
//! viene con la fase 3.
use gpui::{
Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div,
prelude::*, px,
};
use tahuantinsuyu_canvas::{
AstrologyCanvas, CanvasMode, ThumbnailItem, ThumbnailScope,
};
use tahuantinsuyu_engine::compute_mock;
use tahuantinsuyu_model::TreeSelection;
use tahuantinsuyu_panel::{ControlPanel, PanelEvent};
use tahuantinsuyu_store::Store;
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
use yahweh_bus::AppBus;
use yahweh_theme::Theme;
const TREE_WIDTH: f32 = 280.0;
const PANEL_HEIGHT: f32 = 180.0;
pub struct Shell {
store: Store,
#[allow(dead_code)]
bus: Entity<AppBus>,
tree: Entity<TahuantinsuyuTree>,
canvas: Entity<AstrologyCanvas>,
panel: Entity<ControlPanel>,
}
impl Shell {
pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
let bus = cx.new(|_| AppBus);
let tree = cx.new(|cx| TahuantinsuyuTree::new(store.clone(), cx));
let canvas = cx.new(AstrologyCanvas::new);
let panel = cx.new(ControlPanel::new);
// Tree → Shell: aplicar selección al canvas/panel.
cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
this.on_tree_event(ev, cx);
})
.detach();
// Panel → Shell: persistir y propagar al canvas.
cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
this.on_panel_event(ev, cx);
})
.detach();
Self {
store,
bus,
tree,
canvas,
panel,
}
}
fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
let selection = match ev {
TreeEvent::Selected(s) => s,
TreeEvent::Opened(s) => s,
};
self.apply_selection(selection.clone(), cx);
}
fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
match sel {
TreeSelection::Chart(id) => {
let chart = match self.store.get_chart(id) {
Ok(c) => c,
Err(e) => {
eprintln!("[shell] get_chart {}: {}", id, e);
return;
}
};
let kind = chart.kind;
let render = compute_mock(&chart);
self.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Wheel {
render: Box::new(render),
},
cx,
);
});
self.panel
.update(cx, |p, cx| p.set_active_kind(Some(kind), cx));
}
TreeSelection::Contact(id) => {
let charts = self.store.list_charts(id).unwrap_or_default();
let items: Vec<ThumbnailItem> = charts
.into_iter()
.map(|c| ThumbnailItem {
chart_id: c.id,
label: SharedString::from(c.label),
subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
preview: None,
})
.collect();
self.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Thumbnails {
scope: ThumbnailScope::Contact(id),
items,
},
cx,
);
});
self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
}
TreeSelection::Group(id) => {
let charts = self.store.charts_under_group(id).unwrap_or_default();
let items: Vec<ThumbnailItem> = charts
.into_iter()
.map(|c| ThumbnailItem {
chart_id: c.id,
label: SharedString::from(c.label),
subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
preview: None,
})
.collect();
self.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Thumbnails {
scope: ThumbnailScope::Group(id),
items,
},
cx,
);
});
self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
}
}
}
fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
match ev {
PanelEvent::ModuleToggled { module_id, .. } => {
self.canvas
.update(cx, |c, cx| c.toggle_module(module_id, cx));
}
PanelEvent::ControlChanged { .. } => {
// Fase 4: aplicar config al canvas + persistir en store.
}
}
// Silenciar warnings de campos no leídos hasta que la fase 2
// cablee CRUD desde el tree.
let _ = (&self.store, &self.tree, &self.bus);
}
}
impl Render for Shell {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let header = div()
.h(px(34.0))
.px(px(12.0))
.flex()
.flex_row()
.items_center()
.gap(px(10.0))
.border_b_1()
.border_color(theme.border)
.child(
div()
.text_size(px(13.0))
.text_color(theme.fg_text)
.child("☉ Tahuantinsuyu"),
)
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child("estudio de astrología profesional"),
);
let tree_panel = div()
.w(px(TREE_WIDTH))
.min_w(px(TREE_WIDTH))
.h_full()
.border_r_1()
.border_color(theme.border)
.child(self.tree.clone());
let canvas_panel = div()
.flex_grow()
.h_full()
.child(self.canvas.clone());
let main_row = div()
.flex_grow()
.flex()
.flex_row()
.child(tree_panel)
.child(canvas_panel);
let bottom_panel = div()
.h(px(PANEL_HEIGHT))
.min_h(px(PANEL_HEIGHT))
.w_full()
.border_t_1()
.border_color(theme.border)
.child(self.panel.clone());
div()
.size_full()
.bg(theme.bg_app.clone())
.flex()
.flex_col()
.child(header)
.child(main_row)
.child(bottom_panel)
}
}