diff --git a/Cargo.lock b/Cargo.lock index 233634e..bdda8b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1584,6 +1584,15 @@ dependencies = [ "unicode-security", ] +[[package]] +name = "celestial-eop-data" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0db7627f7cbdcaed155e66503e07025e10701a3566bc211a85e35b918bc40812" +dependencies = [ + "zstd", +] + [[package]] name = "cexpr" version = "0.6.0" @@ -2513,6 +2522,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "4.0.0" @@ -3239,6 +3257,88 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "eternal-astrology" +version = "0.1.1-alpha.2" +dependencies = [ + "eternal-core", + "eternal-ephemeris", + "eternal-sky", + "eternal-time", + "eternal-validation", + "libm", + "thiserror 2.0.18", +] + +[[package]] +name = "eternal-coords" +version = "0.1.1-alpha.2" +dependencies = [ + "celestial-eop-data", + "eternal-core", + "eternal-time", + "libm", + "thiserror 2.0.18", +] + +[[package]] +name = "eternal-core" +version = "0.1.1-alpha.2" +dependencies = [ + "libm", + "once_cell", + "regex", + "thiserror 2.0.18", +] + +[[package]] +name = "eternal-ephemeris" +version = "0.1.1-alpha.2" +dependencies = [ + "eternal-coords", + "eternal-core", + "eternal-time", + "libm", + "memmap2", +] + +[[package]] +name = "eternal-sky" +version = "0.1.1-alpha.2" +dependencies = [ + "eternal-coords", + "eternal-core", + "eternal-ephemeris", + "eternal-time", + "eternal-validation", + "libm", + "thiserror 2.0.18", +] + +[[package]] +name = "eternal-time" +version = "0.1.1-alpha.2" +dependencies = [ + "eternal-core", + "libm", + "thiserror 2.0.18", +] + +[[package]] +name = "eternal-validation" +version = "0.1.1-alpha.2" +dependencies = [ + "anyhow", + "clap", + "eternal-coords", + "eternal-core", + "eternal-ephemeris", + "eternal-time", + "libm", + "serde", + "serde_json", +] + [[package]] name = "euclid" version = "0.22.14" @@ -10800,6 +10900,120 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tahuantinsuyu" +version = "0.1.0" +dependencies = [ + "directories", + "gpui", + "tahuantinsuyu-canvas", + "tahuantinsuyu-card", + "tahuantinsuyu-engine", + "tahuantinsuyu-model", + "tahuantinsuyu-modules", + "tahuantinsuyu-panel", + "tahuantinsuyu-store", + "tahuantinsuyu-theme", + "tahuantinsuyu-tree", + "yahweh-bus", + "yahweh-theme", +] + +[[package]] +name = "tahuantinsuyu-canvas" +version = "0.1.0" +dependencies = [ + "gpui", + "tahuantinsuyu-engine", + "tahuantinsuyu-model", + "tahuantinsuyu-modules", + "tahuantinsuyu-theme", + "yahweh-theme", +] + +[[package]] +name = "tahuantinsuyu-card" +version = "0.1.0" +dependencies = [ + "brahman-card", + "brahman-sidecar", + "ulid", +] + +[[package]] +name = "tahuantinsuyu-engine" +version = "0.1.0" +dependencies = [ + "eternal-astrology", + "eternal-sky", + "serde", + "tahuantinsuyu-model", + "thiserror 2.0.18", +] + +[[package]] +name = "tahuantinsuyu-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "ulid", +] + +[[package]] +name = "tahuantinsuyu-modules" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tahuantinsuyu-engine", + "tahuantinsuyu-model", +] + +[[package]] +name = "tahuantinsuyu-panel" +version = "0.1.0" +dependencies = [ + "gpui", + "serde_json", + "tahuantinsuyu-model", + "tahuantinsuyu-modules", + "tahuantinsuyu-theme", + "yahweh-theme", +] + +[[package]] +name = "tahuantinsuyu-store" +version = "0.1.0" +dependencies = [ + "rusqlite", + "serde", + "serde_json", + "tahuantinsuyu-model", + "thiserror 2.0.18", + "ulid", +] + +[[package]] +name = "tahuantinsuyu-theme" +version = "0.1.0" +dependencies = [ + "gpui", + "yahweh-theme", +] + +[[package]] +name = "tahuantinsuyu-tree" +version = "0.1.0" +dependencies = [ + "gpui", + "tahuantinsuyu-model", + "tahuantinsuyu-store", + "yahweh-theme", + "yahweh-widget-tree", +] + [[package]] name = "take-until" version = "0.2.0" @@ -14011,6 +14225,34 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 9eaf3d9..19ca9ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,6 +133,19 @@ members = [ # ============================================================ "crates/modules/barra/barra-web", + # ============================================================ + # modules/tahuantinsuyu/ — estudio de astrología profesional + # ============================================================ + "crates/modules/tahuantinsuyu/tahuantinsuyu-card", + "crates/modules/tahuantinsuyu/tahuantinsuyu-model", + "crates/modules/tahuantinsuyu/tahuantinsuyu-store", + "crates/modules/tahuantinsuyu/tahuantinsuyu-engine", + "crates/modules/tahuantinsuyu/tahuantinsuyu-modules", + "crates/modules/tahuantinsuyu/tahuantinsuyu-theme", + "crates/modules/tahuantinsuyu/tahuantinsuyu-canvas", + "crates/modules/tahuantinsuyu/tahuantinsuyu-tree", + "crates/modules/tahuantinsuyu/tahuantinsuyu-panel", + # ============================================================ # apps/ — apps que consumen el protocolo (yahweh modules+shell) # ============================================================ @@ -156,6 +169,7 @@ members = [ "crates/apps/lapaloma-stream-demo", "crates/apps/lapaloma-phosphor-demo", "crates/apps/lapaloma-financial-demo", + "crates/apps/tahuantinsuyu", ] [workspace.package] diff --git a/crates/apps/tahuantinsuyu/Cargo.toml b/crates/apps/tahuantinsuyu/Cargo.toml new file mode 100644 index 0000000..a8c02d7 --- /dev/null +++ b/crates/apps/tahuantinsuyu/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tahuantinsuyu" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — estudio profesional de astrología. Tree + canvas + panel sobre yahweh + eternal-astrology." + +[dependencies] +tahuantinsuyu-card = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-card" } +tahuantinsuyu-canvas = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-canvas" } +tahuantinsuyu-engine = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-engine" } +tahuantinsuyu-model = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-model" } +tahuantinsuyu-modules = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-modules" } +tahuantinsuyu-panel = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-panel" } +tahuantinsuyu-store = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-store" } +tahuantinsuyu-theme = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-theme" } +tahuantinsuyu-tree = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-tree" } + +yahweh-bus = { workspace = true } +yahweh-theme = { workspace = true } +gpui = { workspace = true } +directories = { workspace = true } + +[[bin]] +name = "tahuantinsuyu" +path = "src/main.rs" diff --git a/crates/apps/tahuantinsuyu/src/main.rs b/crates/apps/tahuantinsuyu/src/main.rs new file mode 100644 index 0000000..a0ca5a2 --- /dev/null +++ b/crates/apps/tahuantinsuyu/src/main.rs @@ -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) +} diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs new file mode 100644 index 0000000..476f8cd --- /dev/null +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -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, + tree: Entity, + canvas: Entity, + panel: Entity, +} + +impl Shell { + pub fn new(store: Store, cx: &mut Context) -> Self { + cx.observe_global::(|_, 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) { + 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) { + 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 = 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 = 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) { + 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) -> 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) + } +} diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml new file mode 100644 index 0000000..fdc1780 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "tahuantinsuyu-canvas" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — widget GPUI del canvas astrológico. Capas modulares, jog-dial perimetral, estado unificado." + +[dependencies] +tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" } +tahuantinsuyu-model = { path = "../tahuantinsuyu-model" } +tahuantinsuyu-modules = { path = "../tahuantinsuyu-modules" } +tahuantinsuyu-theme = { path = "../tahuantinsuyu-theme" } +yahweh-theme = { workspace = true } +gpui = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs new file mode 100644 index 0000000..88b504f --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs @@ -0,0 +1,264 @@ +//! `tahuantinsuyu-canvas` — el widget GPUI del lienzo astrológico. +//! +//! Modela el cielo como un lienzo de **geometría reactiva**: un estado +//! unificado [`CanvasState`] guarda offsets de rotación, flags de +//! visibilidad y la lista de `Layer`s a pintar. Las interacciones +//! (drag, hotkeys, toggles) mutan el estado; el render lee la última +//! `RenderModel` y la deriva al frame. +//! +//! ## Modos +//! +//! - [`CanvasMode::Wheel`] — pinta una carta única (la rueda). +//! - [`CanvasMode::Thumbnails`] — pinta una grilla de mini-cartas +//! cuando el item activo del tree es un Group o Contact. +//! - [`CanvasMode::Empty`] — sin selección. +//! +//! ## Fase 1 +//! +//! Este crate trae el esqueleto: tipos, estado, render placeholder +//! (caja cuadrada con título centrado, eje cardinal y un anillo +//! perfilado). Las interacciones del jog-dial, el árbol Uraniano y la +//! pintura de cada `Layer` vienen en fases siguientes. + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +use gpui::{ + Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*, px, +}; + +use tahuantinsuyu_engine::RenderModel; +use tahuantinsuyu_model::{ChartId, ContactId, GroupId}; +use tahuantinsuyu_theme::AstroPalette; +use yahweh_theme::Theme; + +// ===================================================================== +// Eventos +// ===================================================================== + +#[derive(Clone, Debug)] +pub enum CanvasEvent { + /// El usuario hizo doble click sobre un thumbnail o pidió abrir la + /// carta activa. El host (la app) decide si emitir al AppBus. + ChartRequested(ChartId), + /// El usuario rotó la rueda de tiempo: minutos de offset acumulados. + TimeOffsetChanged(i64), +} + +// ===================================================================== +// Estado +// ===================================================================== + +/// Modo de visualización del canvas. +#[derive(Clone, Debug, Default)] +pub enum CanvasMode { + #[default] + Empty, + /// Single chart wheel. + Wheel { render: Box }, + /// Grilla de thumbnails para un Group o Contact con varias cartas. + Thumbnails { + scope: ThumbnailScope, + items: Vec, + }, +} + +#[derive(Clone, Debug)] +pub enum ThumbnailScope { + Group(GroupId), + Contact(ContactId), +} + +#[derive(Clone, Debug)] +pub struct ThumbnailItem { + pub chart_id: ChartId, + pub label: SharedString, + pub subtitle: Option, + /// `Some` si ya hay un render-mock disponible. `None` = lazy. + pub preview: Option, +} + +/// Estado unificado del canvas. Inspirado en la conversación de Sergio +/// con el agente — todo lo que controla qué se pinta vive acá. +#[derive(Clone, Debug, Default)] +pub struct CanvasState { + pub mode: CanvasMode, + + /// Rotación manual del lienzo en grados. `0.0` = Aries al este. + pub view_rotation_deg: f32, + + /// Offset acumulado del time-scrubbing (jog-dial perimetral) en + /// minutos. La engine recalcula la `RenderModel` cuando esto cambia. + pub time_offset_minutes: i64, + + /// Capas activas por `module_id`. Si una capa del `RenderModel` + /// pertenece a un módulo no presente aquí, no se pinta. + pub active_modules: std::collections::HashSet, +} + +// ===================================================================== +// Widget +// ===================================================================== + +pub struct AstrologyCanvas { + state: CanvasState, +} + +impl EventEmitter for AstrologyCanvas {} + +impl AstrologyCanvas { + pub fn new(cx: &mut Context) -> Self { + cx.observe_global::(|_, cx| cx.notify()).detach(); + Self { + state: CanvasState::default(), + } + } + + pub fn state(&self) -> &CanvasState { + &self.state + } + + /// Reemplaza el modo de visualización (lo que se pinta). + pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context) { + self.state.mode = mode; + cx.notify(); + } + + pub fn toggle_module(&mut self, module_id: &str, cx: &mut Context) { + if !self.state.active_modules.remove(module_id) { + self.state.active_modules.insert(module_id.to_string()); + } + cx.notify(); + } + + pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context) { + self.state.view_rotation_deg = deg.rem_euclid(360.0); + cx.notify(); + } +} + +// ===================================================================== +// Render +// ===================================================================== + +impl Render for AstrologyCanvas { + fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + let palette = AstroPalette::for_theme(&theme); + + let body = match &self.state.mode { + CanvasMode::Empty => render_empty(&theme), + CanvasMode::Wheel { render } => render_wheel(&theme, &palette, render), + CanvasMode::Thumbnails { scope: _, items } => { + render_thumbnails(&theme, &palette, items) + } + }; + + div() + .size_full() + .bg(theme.bg_panel.clone()) + .flex() + .flex_col() + .items_center() + .justify_center() + .child(body) + } +} + +fn render_empty(theme: &Theme) -> gpui::Div { + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(12.0)) + .child( + div() + .text_size(px(13.0)) + .text_color(theme.fg_muted) + .child("Tahuantinsuyu"), + ) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_disabled) + .child("Seleccioná una carta en el árbol para empezar."), + ) +} + +fn render_wheel(theme: &Theme, palette: &AstroPalette, render: &RenderModel) -> gpui::Div { + // Fase 1: placeholder visual. Una caja cuadrada con el título y un + // contador de capas. El pintado real de los Layer vendrá con + // `gpui::canvas` + matrices en la fase 3. + let _ = palette; // silencia warning hasta la fase 3. + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(10.0)) + .child( + div() + .text_size(px(16.0)) + .text_color(theme.fg_text) + .child(SharedString::from(render.title.clone())), + ) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_muted) + .child(SharedString::from(format!( + "{} capa(s) · {} ms", + render.layers.len(), + render.compute_ms + ))), + ) + .child( + // Marco cuadrado provisional — el render real lo ocupará. + div() + .size(px(480.0)) + .rounded(px(8.0)) + .border_1() + .border_color(theme.border_strong) + .bg(theme.bg_panel_alt.clone()), + ) +} + +fn render_thumbnails( + theme: &Theme, + _palette: &AstroPalette, + items: &[ThumbnailItem], +) -> gpui::Div { + if items.is_empty() { + return div() + .text_size(px(12.0)) + .text_color(theme.fg_muted) + .child("Sin cartas en este grupo todavía."); + } + // Grid simple en flex-wrap. La fase 3 lo reemplaza por miniaturas + // pintadas con la rueda en miniatura. + let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0)); + for it in items { + row = row.child( + div() + .w(px(140.0)) + .h(px(160.0)) + .rounded(px(6.0)) + .border_1() + .border_color(theme.border) + .bg(theme.bg_panel_alt.clone()) + .flex() + .flex_col() + .items_center() + .justify_end() + .pb(px(8.0)) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_text) + .child(it.label.clone()), + ), + ); + } + row +} diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml new file mode 100644 index 0000000..985c14b --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "tahuantinsuyu-card" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — Tarjeta de Presentación brahman + spawn del sidecar." + +[dependencies] +brahman-card = { path = "../../../core/brahman-card" } +brahman-sidecar = { path = "../../../shared/brahman-sidecar" } +ulid = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs new file mode 100644 index 0000000..e558fcf --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs @@ -0,0 +1,89 @@ +//! `tahuantinsuyu-card` — Tarjeta de Presentación + sidecar de la app. +//! +//! Cualquier binario que levante Tahuantinsuyu llama [`spawn_sidecar`] +//! antes de abrir la ventana GPUI. La lógica de thread / tokio / +//! ping-loop vive en `brahman-sidecar`; aquí solo declaramos quién es +//! Tahuantinsuyu como módulo Brahman. + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +use std::collections::BTreeSet; + +use brahman_card::{ + Card, Flow, Flows, FsPolicy, IpcPolicy, Lifecycle, Payload, Permissions, Priority, Supervision, + TypeRef, CARD_SCHEMA_VERSION, +}; +use ulid::Ulid; + +/// Label canónico — coincide con el binario y aparece en `ListEntes`. +pub const LABEL: &str = "brahman.tahuantinsuyu"; + +/// Spawn fire-and-forget. Si el Init no está corriendo, el sidecar +/// loggea y termina; la app sigue ejecutándose standalone. +pub fn spawn_sidecar() { + brahman_sidecar::spawn(build_card()); +} + +/// Construye la Card. Expuesto público para tests + para shells que +/// quieran inspeccionar el manifiesto antes de spawnear. +pub fn build_card() -> Card { + Card { + schema_version: CARD_SCHEMA_VERSION, + id: Ulid::new(), + lineage: None, + label: LABEL.into(), + provides: BTreeSet::new(), + requires: BTreeSet::new(), + payload: Payload::Virtual, + supervision: Supervision::Delegate, + lifecycle: Lifecycle::Widget, + priority: Priority::Normal, + permissions: Permissions { + // La app guarda su DB SQLite en disco; necesita RW filesystem. + filesystem: FsPolicy::ReadWrite, + ipc: IpcPolicy { + allow: vec!["wit-v1".into()], + }, + ..Default::default() + }, + flow: Flows { + // Recibe peticiones de cómputo (carta natal, transit, etc.) + // serializadas como JSON. La forma exacta la define + // `tahuantinsuyu-engine`. + input: vec![Flow { + name: "chart-request".into(), + ty: TypeRef::Primitive { + name: "json".into(), + }, + pin_to: None, + }], + // Publica el resultado de un cómputo (placements, aspectos, + // casas) también como JSON. Otras apps brahman pueden + // consumirlo para visualizar o derivar. + output: vec![Flow { + name: "chart-result".into(), + ty: TypeRef::Primitive { + name: "json".into(), + }, + pin_to: None, + }], + }, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn card_label_and_flow() { + let c = build_card(); + assert_eq!(c.label, LABEL); + assert_eq!(c.flow.input.len(), 1); + assert_eq!(c.flow.output.len(), 1); + assert_eq!(c.flow.input[0].name, "chart-request"); + assert_eq!(c.flow.output[0].name, "chart-result"); + } +} diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml new file mode 100644 index 0000000..4940e95 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tahuantinsuyu-engine" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — bridge entre el modelo agnóstico y eternal-astrology. Produce RenderModel agnóstico para el canvas." + +[dependencies] +tahuantinsuyu-model = { path = "../tahuantinsuyu-model" } +serde = { workspace = true } +thiserror = { workspace = true } + +# eternal-astrology vive en otro workspace (~/eternal). Lo enlazamos por +# path para que el bridge use la misma lógica validada que el harness de +# Sergio. Si el path no existe (CI sin eternal checked out), el feature +# `eternal-bridge` se apaga. +[dependencies.eternal-astrology] +path = "../../../../../eternal/eternal-astrology" +optional = true + +[dependencies.eternal-sky] +path = "../../../../../eternal/eternal-sky" +optional = true + +[features] +default = [] +# Activa el bridge real contra eternal-astrology. Sin este feature, la +# engine sólo expone el RenderModel y mocks — útil para tests y para +# compilar la UI antes de que eternal esté disponible. +eternal-bridge = ["dep:eternal-astrology", "dep:eternal-sky"] diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs new file mode 100644 index 0000000..51522a7 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs @@ -0,0 +1,272 @@ +//! `tahuantinsuyu-engine` — bridge entre el modelo agnóstico y +//! `eternal-astrology`. +//! +//! Recibe un `Chart` del modelo + un `ChartKind` y devuelve un +//! [`RenderModel`] que describe la geometría a pintar **sin** acoplar +//! el canvas a tipos de la librería astronómica. El canvas habla +//! grados decimales, radios normalizados y kinds simbólicos. +//! +//! ## Por qué un RenderModel intermedio +//! +//! 1. El canvas no debería caer si cambia el shape de `NatalChart` +//! upstream. +//! 2. Tests del canvas: podemos generar `RenderModel`s sintéticos sin +//! arrancar eternal. +//! 3. Cada `ChartKind` produce el mismo shape genérico → el render +//! coordina N módulos sin saber qué calcularon. +//! +//! ## Feature `eternal-bridge` +//! +//! - **off** (default): la engine sólo expone los tipos `RenderModel`, +//! `Layer`, `Glyph`, etc. y un `compute_mock()` con un disco de +//! prueba. Útil para la UI antes de que `eternal-astrology` compile. +//! - **on**: agrega `compute(chart) -> RenderModel` con la pipeline +//! real. + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind}; + +// ===================================================================== +// RenderModel — lo que el canvas necesita pintar una capa +// ===================================================================== + +/// Resultado agnóstico de un cómputo astrológico, listo para renderizar. +/// Cada `Layer` es independiente — el canvas las apila por z-order. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenderModel { + /// Identidad estable de la carta a la que pertenece este render. + pub chart_id: ChartId, + /// Kind original — el canvas lo usa para títulos y ornamentos. + pub chart_kind: ChartKind, + /// Capas a pintar. Orden = z-order ascendente. + pub layers: Vec, + /// Texto humano-legible breve. Ej. "Sergio · 14 mar 1987 · Caracas". + pub title: String, + /// Tiempo de cómputo en ms — métrica para diagnóstico. + pub compute_ms: u64, +} + +/// Una capa visual. Cada módulo de astrología publica una o varias. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Layer { + /// Identidad estable del módulo emisor ("natal", "transit", "uranian"). + pub module_id: String, + /// Tipo de capa — controla cómo se compone con vecinas. + pub kind: LayerKind, + /// Radio normalizado [0, 1] sobre el lienzo. Permite stack de anillos. + pub ring: f32, + /// Z-order absoluto (más alto = encima). Default 0. + #[serde(default)] + pub z: i32, + /// Geometría: puntos, arcos, líneas. + pub geometry: Geometry, + /// Glifos simbólicos sobre la geometría (planetas, signos, casas). + #[serde(default)] + pub glyphs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LayerKind { + /// El anillo zodiacal de fondo (12 signos). + SignDial, + /// Las 12 cusps de casas + cuadrantes. + Houses, + /// Los planetas / cuerpos en sus posiciones. + Bodies, + /// Líneas de aspecto entre cuerpos. + Aspects, + /// Puntos arábigos / lots. + Lots, + /// Estrellas fijas como overlay. + FixedStars, + /// Puntos medios y simetría Uraniana. + Midpoints, + /// Anillo externo de tránsitos / progresiones / direcciones. + Outer, + /// Geometría libre — usa cuando una capa no encaja en las otras. + Custom, +} + +/// Geometría primitiva, agnóstica del renderer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Geometry { + /// Sólo glifos posicionados — sin trazo de fondo. + GlyphsOnly, + /// Anillo dividido en sectores (zodíaco, casas). + Ring { + /// Divisiones en grados zodiacales [0, 360). El canvas pinta + /// líneas radiales en cada uno. + cusps_deg: Vec, + }, + /// Conjunto de líneas (aspectos). Cada par = `(from_deg, to_deg)`. + Lines(Vec), + /// Puntos sueltos con marcadores (lots, fixed stars). + Points(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LineSeg { + pub from_deg: f32, + pub to_deg: f32, + /// Categoría simbólica (conjunction, trine, square…) — el theme + /// resuelve el color. + pub kind: String, + /// Opacidad sugerida [0, 1]. + pub opacity: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PointMark { + pub deg: f32, + pub label: String, + /// Tag simbólico para que el theme elija color/glifo. + pub tag: String, +} + +/// Glifo dibujable sobre una capa. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Glyph { + /// Posición zodiacal en grados [0, 360). + pub deg: f32, + /// Glyph simbólico ("sun","moon","aries",…). El theme lo mapea a + /// imagen o codepoint. + pub symbol: String, + /// Texto secundario (ej. el grado dentro del signo). + #[serde(default)] + pub annotation: Option, + /// `true` si el cuerpo está retrógrado. + #[serde(default)] + pub retrograde: bool, + /// Casa en la que cae (1..=12), si aplica. + #[serde(default)] + pub house: Option, +} + +// ===================================================================== +// Errores +// ===================================================================== + +#[derive(Debug, Error)] +pub enum EngineError { + #[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")] + BridgeDisabled, + #[error("model: {0}")] + Model(#[from] tahuantinsuyu_model::ModelError), + #[cfg(feature = "eternal-bridge")] + #[error("eternal: {0}")] + Eternal(String), +} + +// ===================================================================== +// API pública +// ===================================================================== + +/// Computa el RenderModel real contra `eternal-astrology`. Requiere +/// el feature `eternal-bridge`. +#[cfg(feature = "eternal-bridge")] +pub fn compute(_chart: &Chart) -> Result { + // TODO: pipeline real — abrir `EphemerisSession`, traducir + // `StoredBirthData → BirthData`, `StoredChartConfig → ChartConfig`, + // correr `NatalChart::compute`, mapear a `Layer`s. Se cablea en la + // fase 3 del plan. + Err(EngineError::Eternal("pendiente fase 3".into())) +} + +/// Stub que devuelve un disco vacío de placeholder — sirve a la UI +/// mientras la pipeline real no esté cableada. Usar en demos y +/// desarrollo. +pub fn compute_mock(chart: &Chart) -> RenderModel { + use std::time::Instant; + let t0 = Instant::now(); + + let sign_dial = Layer { + module_id: "natal".into(), + kind: LayerKind::SignDial, + ring: 0.95, + z: 0, + geometry: Geometry::Ring { + cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(), + }, + glyphs: (0..12) + .map(|i| Glyph { + deg: (i as f32) * 30.0 + 15.0, + symbol: ZODIAC_GLYPHS[i].into(), + annotation: None, + retrograde: false, + house: None, + }) + .collect(), + }; + + RenderModel { + chart_id: chart.id, + chart_kind: chart.kind, + layers: vec![sign_dial], + title: chart.label.clone(), + compute_ms: t0.elapsed().as_millis() as u64, + } +} + +const ZODIAC_GLYPHS: [&str; 12] = [ + "aries", + "taurus", + "gemini", + "cancer", + "leo", + "virgo", + "libra", + "scorpio", + "sagittarius", + "capricorn", + "aquarius", + "pisces", +]; + +#[cfg(test)] +mod tests { + use super::*; + use tahuantinsuyu_model::{ + Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig, + }; + + fn sample_chart() -> Chart { + Chart { + id: ChartId::new(), + contact_id: ContactId::new(), + kind: ChartKind::Natal, + label: "test".into(), + birth_data: StoredBirthData { + year: 1987, + month: 3, + day: 14, + hour: 5, + minute: 22, + second: 0.0, + tz_offset_minutes: -240, + latitude_deg: 10.4806, + longitude_deg: -66.9036, + altitude_m: 900.0, + time_certainty: Default::default(), + subject_name: None, + birthplace_label: None, + }, + config: StoredChartConfig::default(), + related_chart_id: None, + created_at_ms: 0, + } + } + + #[test] + fn mock_emits_sign_dial() { + let model = compute_mock(&sample_chart()); + assert_eq!(model.layers.len(), 1); + assert!(matches!(model.layers[0].kind, LayerKind::SignDial)); + assert_eq!(model.layers[0].glyphs.len(), 12); + } +} diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/Cargo.toml new file mode 100644 index 0000000..ada92be --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "tahuantinsuyu-model" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — tipos agnósticos del modelo astrológico (Group, Contact, Chart, StoredBirthData, StoredChartConfig)." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +ulid = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs new file mode 100644 index 0000000..52a4604 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs @@ -0,0 +1,363 @@ +//! `tahuantinsuyu-model` — tipos agnósticos del estudio astrológico. +//! +//! Esta es la capa de **datos puros**: no conoce GPUI, ni rusqlite, ni +//! `eternal-astrology`. Solo tipos `serde`-able que viajan entre la +//! store, la engine, los widgets, y eventualmente la Card de Brahman. +//! +//! ## Jerarquía +//! +//! ```text +//! Group (puede anidar otros Groups vía parent_id) +//! ├── Group (sub-agrupación) +//! └── Contact (persona / evento / lugar) +//! └── Chart (carta astrológica) +//! ``` +//! +//! Las `Chart` son las hojas — cada una guarda su `StoredBirthData` y su +//! `StoredChartConfig`. La engine las traduce a tipos de `eternal-astrology` +//! cuando hay que computar. +//! +//! ## Por qué tipos "Stored" propios y no reusar `eternal-astrology` +//! +//! Forward-compat: si mañana cambia el shape de `BirthData` upstream, o +//! queremos persistir en otro backend astronómico, el modelo + la base +//! sobreviven. La engine es el único puente que conoce ambas formas. + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use ulid::Ulid; + +pub use ::ulid; + +// ===================================================================== +// Identidades +// ===================================================================== + +macro_rules! ulid_newtype { + ($name:ident, $doc:expr) => { + #[doc = $doc] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub struct $name(pub Ulid); + + impl $name { + pub fn new() -> Self { + Self(Ulid::new()) + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } + + impl std::str::FromStr for $name { + type Err = ulid::DecodeError; + fn from_str(s: &str) -> Result { + Ulid::from_string(s).map(Self) + } + } + }; +} + +ulid_newtype!(GroupId, "Identificador estable de un Group."); +ulid_newtype!(ContactId, "Identificador estable de un Contact."); +ulid_newtype!(ChartId, "Identificador estable de un Chart."); + +// ===================================================================== +// Group / Contact +// ===================================================================== + +/// Agrupación jerárquica de contactos. Puede anidar otros groups vía +/// `parent_id` (un Group raíz tiene `parent_id = None`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Group { + pub id: GroupId, + pub parent_id: Option, + pub name: String, + #[serde(default)] + pub description: Option, + /// Epoch millis. Decisión: `i64` para tolerar valores pre-1970 en + /// imports históricos sin overflow. + pub created_at_ms: i64, + /// Orden manual dentro del padre. Más bajo = primero. Empate → por nombre. + #[serde(default)] + pub sort_order: i32, +} + +/// Persona o evento del que se calcula una o más cartas. Puede vivir +/// directamente en la raíz (`group_id = None`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Contact { + pub id: ContactId, + pub group_id: Option, + pub name: String, + #[serde(default)] + pub notes: Option, + pub created_at_ms: i64, +} + +// ===================================================================== +// Datos de nacimiento (espejo agnóstico de eternal_astrology::BirthData) +// ===================================================================== + +/// Datos crudos de nacimiento. La engine los traduce a +/// `eternal_astrology::BirthData` cuando hay que computar. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredBirthData { + /// Calendario civil local. + pub year: i32, + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, + /// Segundos fraccionarios (0.0..60.0). + pub second: f64, + /// Offset desde UTC, en minutos. Ej: -240 = UTC-04:00. + pub tz_offset_minutes: i32, + + /// Coordenadas geográficas en grados decimales. + pub latitude_deg: f64, + pub longitude_deg: f64, + /// Altura en metros sobre el geoide WGS-84. + #[serde(default)] + pub altitude_m: f64, + + #[serde(default)] + pub time_certainty: TimeCertainty, + #[serde(default)] + pub subject_name: Option, + #[serde(default)] + pub birthplace_label: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TimeCertainty { + #[default] + Exact, + RoundedHour, + RoundedDay, + Estimated, +} + +// ===================================================================== +// Configuración de carta (espejo agnóstico de eternal_astrology::ChartConfig) +// ===================================================================== + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Zodiac { + #[default] + Tropical, + Sidereal, + Draconic, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HouseSystem { + #[default] + Placidus, + Koch, + Regiomontanus, + Campanus, + Porphyry, + Equal, + WholeSign, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredChartConfig { + #[serde(default)] + pub zodiac: Zodiac, + #[serde(default)] + pub house_system: HouseSystem, + /// Nombre del ayanamsha cuando `zodiac == Sidereal`. Ej: "lahiri", + /// "fagan_bradley". Ignorado para Tropical/Draconic. + #[serde(default)] + pub ayanamsha: Option, + /// Cuerpos a incluir. Strings opacos para que el modelo no se ate + /// al enum `Body` de eternal. Ej: ["sun","moon","mercury",…]. + #[serde(default = "default_bodies")] + pub bodies: Vec, + #[serde(default = "default_true")] + pub include_south_node: bool, + #[serde(default)] + pub include_lilith: bool, + #[serde(default)] + pub include_main_belt_asteroids: bool, + #[serde(default)] + pub include_fixed_stars: bool, + /// Tabla de orbes a usar (nombre simbólico). `None` → orbes defaults + /// de la engine. + #[serde(default)] + pub orb_table: Option, +} + +impl Default for StoredChartConfig { + fn default() -> Self { + Self { + zodiac: Zodiac::default(), + house_system: HouseSystem::default(), + ayanamsha: None, + bodies: default_bodies(), + include_south_node: true, + include_lilith: false, + include_main_belt_asteroids: false, + include_fixed_stars: false, + orb_table: None, + } + } +} + +fn default_bodies() -> Vec { + vec![ + "sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", + "pluto", "mean_node", + ] + .into_iter() + .map(String::from) + .collect() +} + +fn default_true() -> bool { + true +} + +// ===================================================================== +// Chart +// ===================================================================== + +/// Tipo de carta astrológica. Determina qué rutina de la engine corre +/// y qué `Layer`s aporta al canvas. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChartKind { + Natal, + Transit, + SecondaryProgression, + TertiaryProgression, + MinorProgression, + SolarArc, + SolarReturn, + LunarReturn, + Synastry, + Composite, + Davison, + Profection, + PrimaryDirection, + /// Carta "mundial" para un instante + lugar sin sujeto natal. + Mundane, +} + +impl ChartKind { + /// `true` si la carta necesita una segunda carta natal como referencia + /// (synastry/composite/davison). Útil para validar al persistir. + pub fn requires_related_chart(self) -> bool { + matches!( + self, + ChartKind::Synastry | ChartKind::Composite | ChartKind::Davison + ) + } +} + +/// Una carta concreta dentro de un contacto. Las cartas de tipo +/// derivado (transit, progression, synastry, …) referencian la carta +/// natal de la que parten vía `related_chart_id`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Chart { + pub id: ChartId, + pub contact_id: ContactId, + pub kind: ChartKind, + pub label: String, + pub birth_data: StoredBirthData, + pub config: StoredChartConfig, + /// Para cartas derivadas: la carta de referencia. Para transit/ + /// progression apunta a la natal del mismo contacto. Para synastry + /// apunta a la carta del otro sujeto. + #[serde(default)] + pub related_chart_id: Option, + pub created_at_ms: i64, +} + +// ===================================================================== +// Estado de módulos por carta (qué capas están activas + su config) +// ===================================================================== + +/// Cada `ChartKind` puede activar uno o más `module_id` (ej. una carta +/// natal puede tener `natal`, `dignities`, `fixed_stars`, `uranian`). +/// El estado por-carta se persiste en la store; el canvas lo consulta +/// para decidir qué capas pintar y qué controles mostrar en el panel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleState { + pub chart_id: ChartId, + pub module_id: String, + pub enabled: bool, + /// JSON libre — cada módulo define su schema. + #[serde(default)] + pub config: serde_json::Value, +} + +// ===================================================================== +// Selección activa (qué muestra el canvas) +// ===================================================================== + +/// Item activo del tree. El canvas reacciona a este tipo: +/// - `Chart` → abre la carta puntual. +/// - `Contact` / `Group` → muestra thumbnails de las cartas descendientes. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TreeSelection { + Group(GroupId), + Contact(ContactId), + Chart(ChartId), +} + +// ===================================================================== +// Errores +// ===================================================================== + +#[derive(Debug, Error)] +pub enum ModelError { + #[error("chart {kind:?} requiere related_chart_id pero recibió None")] + MissingRelatedChart { kind: ChartKind }, + #[error("group {0} no puede ser su propio ancestro")] + GroupCycle(GroupId), + #[error("invalid field {field}: {reason}")] + InvalidField { + field: &'static str, + reason: String, + }, +} + +impl Chart { + /// Validación liviana: ataja errores que la base no captura + /// (ej. synastry sin `related_chart_id`). + pub fn validate(&self) -> Result<(), ModelError> { + if self.kind.requires_related_chart() && self.related_chart_id.is_none() { + return Err(ModelError::MissingRelatedChart { kind: self.kind }); + } + if !(-90.0..=90.0).contains(&self.birth_data.latitude_deg) { + return Err(ModelError::InvalidField { + field: "latitude_deg", + reason: format!("{} fuera de [-90, 90]", self.birth_data.latitude_deg), + }); + } + if !(-180.0..=180.0).contains(&self.birth_data.longitude_deg) { + return Err(ModelError::InvalidField { + field: "longitude_deg", + reason: format!("{} fuera de [-180, 180]", self.birth_data.longitude_deg), + }); + } + Ok(()) + } +} diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/Cargo.toml new file mode 100644 index 0000000..b6318b9 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "tahuantinsuyu-modules" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — registry de módulos astrológicos (Natal, Transit, Synastry, Uranian, …)." + +[dependencies] +tahuantinsuyu-model = { path = "../tahuantinsuyu-model" } +tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs new file mode 100644 index 0000000..3e4c6b6 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs @@ -0,0 +1,231 @@ +//! `tahuantinsuyu-modules` — registry de módulos astrológicos. +//! +//! Cada tipo de astrología (natal, tránsito, progresión, sinastría, +//! Uraniano, …) es un **módulo** que declara: +//! +//! - Qué `Layer`s aporta al `RenderModel`. +//! - Qué `Control`s expone al panel inferior (toggles, sliders, selects). +//! - Hotkeys opcionales. +//! - Si su cómputo es lazy (sólo cuando se activa) o eager. +//! +//! El registry es un `Vec<&dyn Module>` estático: el canvas consulta +//! "para esta `ChartKind`, ¿qué módulos están disponibles?" y el panel +//! pinta sus controles. Activar / desactivar persiste en +//! `ModuleState` (en la store). +//! +//! Esta fase 1 trae el trait + un módulo `NatalModule` de placeholder. +//! En fases posteriores agregamos Transit, Progression, Synastry, +//! Composite, SolarArc, Uranian, FixedStars, Dignities, Lots… + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +use serde::{Deserialize, Serialize}; + +use tahuantinsuyu_engine::Layer; +use tahuantinsuyu_model::{Chart, ChartKind}; + +// ===================================================================== +// Trait Module +// ===================================================================== + +/// Una capa de astrología enchufable. +/// +/// `Send + Sync` para que el registry sea estático y se pueda consultar +/// desde cualquier thread (el cómputo pesado va a un background executor). +pub trait Module: Send + Sync { + /// Identidad estable del módulo. Coincide con `ModuleState.module_id` + /// en la store. + fn id(&self) -> &'static str; + + /// Etiqueta amigable para el panel. + fn label(&self) -> &'static str; + + /// Breve descripción para tooltip. + fn description(&self) -> &'static str; + + /// Para qué tipos de carta tiene sentido este módulo. El panel filtra + /// con esto al armar la lista de toggles disponibles. + fn applies_to(&self, kind: ChartKind) -> bool; + + /// Si el módulo está activado por default al crear una carta. + fn enabled_by_default(&self) -> bool { + false + } + + /// Controles que aporta al panel inferior. + fn controls(&self) -> Vec { + Vec::new() + } + + /// Computa las capas que este módulo aporta al RenderModel de + /// `chart`. La engine la llama solo si el módulo está activado + /// para esa carta. + /// + /// Devuelve `Vec` (no Option) — un módulo puede no aportar capas + /// si su config interna lo apaga (ej. "Uranian: mostrar simetría + /// = false"); en ese caso retorna `Vec::new()`. + fn compute_layers(&self, chart: &Chart, config: &serde_json::Value) -> Vec; +} + +// ===================================================================== +// Controls expuestos al panel +// ===================================================================== + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Control { + Toggle { + key: String, + label: String, + default: bool, + hotkey: Option, + }, + Slider { + key: String, + label: String, + min: f64, + max: f64, + step: f64, + default: f64, + }, + Select { + key: String, + label: String, + options: Vec, + default: String, + }, + /// Texto libre — útil para etiquetas, comentarios. + TextInput { + key: String, + label: String, + default: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SelectOption { + pub value: String, + pub label: String, +} + +// ===================================================================== +// Registry +// ===================================================================== + +/// Lista estática de módulos disponibles. La app los registra al boot. +pub struct Registry { + modules: Vec>, +} + +impl Registry { + /// Registry con todos los módulos built-in. La app llama esto al + /// boot y luego usa `find()` / `for_kind()` para consultar. + pub fn with_builtins() -> Self { + let mut r = Self { modules: Vec::new() }; + r.register(Box::new(natal::NatalModule)); + r + } + + pub fn register(&mut self, m: Box) { + self.modules.push(m); + } + + pub fn all(&self) -> &[Box] { + &self.modules + } + + pub fn find(&self, id: &str) -> Option<&dyn Module> { + self.modules + .iter() + .find(|m| m.id() == id) + .map(|m| m.as_ref()) + } + + pub fn for_kind(&self, kind: ChartKind) -> Vec<&dyn Module> { + self.modules + .iter() + .filter(|m| m.applies_to(kind)) + .map(|m| m.as_ref()) + .collect() + } +} + +// ===================================================================== +// NatalModule — placeholder fase 1 +// ===================================================================== + +pub mod natal { + use super::*; + use tahuantinsuyu_engine::compute_mock; + + pub struct NatalModule; + + impl Module for NatalModule { + fn id(&self) -> &'static str { + "natal" + } + fn label(&self) -> &'static str { + "Carta natal" + } + fn description(&self) -> &'static str { + "Posiciones natales, casas y aspectos." + } + fn applies_to(&self, kind: ChartKind) -> bool { + matches!(kind, ChartKind::Natal) + } + fn enabled_by_default(&self) -> bool { + true + } + + fn controls(&self) -> Vec { + vec![ + Control::Toggle { + key: "show_ecliptic".into(), + label: "Eclíptica".into(), + default: true, + hotkey: Some("E".into()), + }, + Control::Toggle { + key: "show_ascensional".into(), + label: "Ascensional".into(), + default: false, + hotkey: Some("A".into()), + }, + Control::Toggle { + key: "show_aspects".into(), + label: "Aspectos".into(), + default: true, + hotkey: None, + }, + Control::Slider { + key: "harmonic".into(), + label: "Armónico".into(), + min: 1.0, + max: 20.0, + step: 1.0, + default: 1.0, + }, + ] + } + + fn compute_layers(&self, chart: &Chart, _cfg: &serde_json::Value) -> Vec { + // Fase 1: delega al mock de la engine para que la UI tenga + // algo que pintar. Fase 3 reemplaza con `engine::compute` + // contra `eternal-astrology`. + compute_mock(chart).layers + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_finds_natal() { + let r = Registry::with_builtins(); + assert!(r.find("natal").is_some()); + assert_eq!(r.for_kind(ChartKind::Natal).len(), 1); + assert!(r.for_kind(ChartKind::Synastry).is_empty()); + } +} diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/Cargo.toml new file mode 100644 index 0000000..37bd9e6 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "tahuantinsuyu-panel" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — panel de control inferior. Toggles, sliders y selectores por módulo de astrología." + +[dependencies] +tahuantinsuyu-model = { path = "../tahuantinsuyu-model" } +tahuantinsuyu-modules = { path = "../tahuantinsuyu-modules" } +tahuantinsuyu-theme = { path = "../tahuantinsuyu-theme" } +yahweh-theme = { workspace = true } +gpui = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs new file mode 100644 index 0000000..5cc5e65 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs @@ -0,0 +1,255 @@ +//! `tahuantinsuyu-panel` — control panel inferior de la app. +//! +//! Lee los módulos disponibles para la carta activa (vía +//! [`tahuantinsuyu_modules::Registry::for_kind`]) y pinta sus +//! [`Control`]s como toggles / sliders / selects. Cada cambio emite +//! [`PanelEvent`] que la app traduce a mutaciones de `ModuleState` en +//! la store y a `toggle_module` sobre el canvas. +//! +//! Fase 1: render placeholder con el listado de módulos disponibles y +//! sus controles, sin handlers cableados todavía (la interacción real +//! viene con la fase 4). + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +use gpui::{ + Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*, px, +}; + +use tahuantinsuyu_model::ChartKind; +use tahuantinsuyu_modules::{Control, Registry}; +use yahweh_theme::Theme; + +// ===================================================================== +// Eventos +// ===================================================================== + +#[derive(Clone, Debug)] +pub enum PanelEvent { + /// Toggle on/off de un módulo entero. + ModuleToggled { module_id: String, enabled: bool }, + /// Cambio de un control puntual. + ControlChanged { + module_id: String, + key: String, + value: serde_json::Value, + }, +} + +// ===================================================================== +// Widget +// ===================================================================== + +pub struct ControlPanel { + /// Módulo activo a mostrar. `None` ⇒ no hay carta seleccionada, + /// pintamos un placeholder. + active_kind: Option, +} + +impl EventEmitter for ControlPanel {} + +impl ControlPanel { + pub fn new(cx: &mut Context) -> Self { + cx.observe_global::(|_, cx| cx.notify()).detach(); + Self { active_kind: None } + } + + pub fn set_active_kind(&mut self, kind: Option, cx: &mut Context) { + self.active_kind = kind; + cx.notify(); + } +} + +impl Render for ControlPanel { + fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + let registry = Registry::with_builtins(); + + let header = div() + .h(px(28.0)) + .px(px(12.0)) + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .border_b_1() + .border_color(theme.border) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_muted) + .child("Panel de control"), + ) + .child(div().ml_auto().text_size(px(10.0)).text_color(theme.fg_disabled).child( + match self.active_kind { + Some(k) => SharedString::from(format!("{:?}", k)), + None => SharedString::from("sin carta activa"), + }, + )); + + let mut body = div() + .flex() + .flex_row() + .flex_wrap() + .gap(px(16.0)) + .px(px(12.0)) + .py(px(8.0)); + + if let Some(kind) = self.active_kind { + for m in registry.for_kind(kind) { + body = body.child(render_module(&theme, m)); + } + } else { + body = body.child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_disabled) + .child("Seleccioná una carta para ver sus controles."), + ); + } + + div() + .size_full() + .bg(theme.bg_panel.clone()) + .flex() + .flex_col() + .child(header) + .child(body) + } +} + +fn render_module(theme: &Theme, m: &dyn tahuantinsuyu_modules::Module) -> gpui::Div { + let header = div() + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .child( + div() + .text_size(px(12.0)) + .text_color(theme.fg_text) + .child(SharedString::from(m.label())), + ) + .child( + div() + .text_size(px(10.0)) + .text_color(theme.fg_muted) + .child(SharedString::from(m.description())), + ); + + let mut controls = div().flex().flex_col().gap(px(4.0)); + for c in m.controls() { + controls = controls.child(render_control(theme, &c)); + } + + div() + .min_w(px(220.0)) + .p(px(8.0)) + .rounded(px(6.0)) + .bg(theme.bg_panel_alt.clone()) + .border_1() + .border_color(theme.border) + .flex() + .flex_col() + .gap(px(6.0)) + .child(header) + .child(controls) +} + +fn render_control(theme: &Theme, c: &Control) -> gpui::Div { + match c { + Control::Toggle { label, default, hotkey, .. } => { + let dot_color = if *default { + theme.accent + } else { + theme.fg_disabled + }; + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .child( + div() + .size(px(8.0)) + .rounded(px(4.0)) + .bg(dot_color), + ) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_text) + .child(SharedString::from(label.clone())), + ) + .child( + div() + .ml_auto() + .text_size(px(10.0)) + .text_color(theme.fg_muted) + .child(SharedString::from( + hotkey.clone().map(|h| format!("[{}]", h)).unwrap_or_default(), + )), + ) + } + Control::Slider { + label, min, max, default, .. + } => div() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_text) + .child(SharedString::from(label.clone())), + ) + .child( + div() + .ml_auto() + .text_size(px(10.0)) + .text_color(theme.fg_muted) + .child(SharedString::from(format!( + "{} ({}…{})", + default, min, max + ))), + ), + Control::Select { label, default, .. } => div() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_text) + .child(SharedString::from(label.clone())), + ) + .child( + div() + .ml_auto() + .text_size(px(10.0)) + .text_color(theme.fg_muted) + .child(SharedString::from(default.clone())), + ), + Control::TextInput { label, default, .. } => div() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_text) + .child(SharedString::from(label.clone())), + ) + .child( + div() + .ml_auto() + .text_size(px(10.0)) + .text_color(theme.fg_muted) + .child(SharedString::from(default.clone())), + ), + } +} diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/Cargo.toml new file mode 100644 index 0000000..de76030 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "tahuantinsuyu-store" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — persistencia SQLite de groups / contacts / charts / module_state." + +[dependencies] +tahuantinsuyu-model = { path = "../tahuantinsuyu-model" } +rusqlite = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +ulid = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs new file mode 100644 index 0000000..d5fa5ba --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs @@ -0,0 +1,557 @@ +//! `tahuantinsuyu-store` — persistencia SQLite del estudio astrológico. +//! +//! Una sola conexión `rusqlite` envuelta en `Arc` para que la app +//! GPUI la comparta entre threads sin pelearse con el ownership. La +//! migración inicial corre la primera vez que se abre un archivo nuevo +//! (idempotente vía `CREATE TABLE IF NOT EXISTS`). +//! +//! Patrón inspirado en `yahweh_provider_sqlite::SqliteDataProvider` pero +//! con dominio propio (no extiende el `DataProvider` agnóstico — esa +//! integración viene en `tahuantinsuyu-tree` que envuelve este store +//! detrás del trait de yahweh). + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rusqlite::{Connection, OptionalExtension, params}; +use thiserror::Error; + +use tahuantinsuyu_model::{ + Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, ModuleState, StoredBirthData, + StoredChartConfig, +}; + +const SCHEMA_VERSION: i32 = 1; + +#[derive(Debug, Error)] +pub enum StoreError { + #[error("sqlite: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("schema downgrade: db is at v{found}, code expects v{expected}")] + SchemaDowngrade { found: i32, expected: i32 }, + #[error("ulid decode: {0}")] + UlidDecode(#[from] ulid::DecodeError), + #[error("model invariant: {0}")] + Model(#[from] tahuantinsuyu_model::ModelError), + #[error("not found: {0}")] + NotFound(String), +} + +pub type StoreResult = Result; + +/// Store backed by a single SQLite file. +/// +/// Clone-able: comparte la misma conexión bajo el mutex. Útil para que +/// distintos widgets (tree, panel, canvas) compartan una vista +/// consistente sin pasar `&mut` por todos lados. +#[derive(Clone)] +pub struct Store { + conn: Arc>, +} + +impl Store { + /// Abre (o crea) un archivo SQLite y corre las migraciones. + pub fn open(path: impl AsRef) -> StoreResult { + let conn = Connection::open(path)?; + let store = Self { + conn: Arc::new(Mutex::new(conn)), + }; + store.migrate()?; + Ok(store) + } + + /// Variante in-memory para tests. + pub fn in_memory() -> StoreResult { + let conn = Connection::open_in_memory()?; + let store = Self { + conn: Arc::new(Mutex::new(conn)), + }; + store.migrate()?; + Ok(store) + } + + fn migrate(&self) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch(MIGRATION_V1)?; + + let found: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; + if found > SCHEMA_VERSION { + return Err(StoreError::SchemaDowngrade { + found, + expected: SCHEMA_VERSION, + }); + } + if found < SCHEMA_VERSION { + conn.execute(&format!("PRAGMA user_version = {}", SCHEMA_VERSION), [])?; + } + Ok(()) + } + + // ----------------------------------------------------------------- + // Groups + // ----------------------------------------------------------------- + + pub fn create_group( + &self, + parent_id: Option, + name: &str, + description: Option<&str>, + ) -> StoreResult { + let group = Group { + id: GroupId::new(), + parent_id, + name: name.into(), + description: description.map(String::from), + created_at_ms: now_ms(), + sort_order: 0, + }; + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO groups (id, parent_id, name, description, created_at_ms, sort_order) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + group.id.to_string(), + group.parent_id.map(|g| g.to_string()), + group.name, + group.description, + group.created_at_ms, + group.sort_order, + ], + )?; + Ok(group) + } + + pub fn list_groups(&self, parent_id: Option) -> StoreResult> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, parent_id, name, description, created_at_ms, sort_order \ + FROM groups WHERE parent_id IS ?1 \ + ORDER BY sort_order ASC, name COLLATE NOCASE ASC", + )?; + let parent_str = parent_id.map(|g| g.to_string()); + let rows = stmt.query_map(params![parent_str], row_to_group)?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn delete_group(&self, id: GroupId) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM groups WHERE id = ?1", params![id.to_string()])?; + Ok(()) + } + + pub fn rename_group(&self, id: GroupId, name: &str) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE groups SET name = ?2 WHERE id = ?1", + params![id.to_string(), name], + )?; + Ok(()) + } + + // ----------------------------------------------------------------- + // Contacts + // ----------------------------------------------------------------- + + pub fn create_contact( + &self, + group_id: Option, + name: &str, + notes: Option<&str>, + ) -> StoreResult { + let c = Contact { + id: ContactId::new(), + group_id, + name: name.into(), + notes: notes.map(String::from), + created_at_ms: now_ms(), + }; + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO contacts (id, group_id, name, notes, created_at_ms) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + c.id.to_string(), + c.group_id.map(|g| g.to_string()), + c.name, + c.notes, + c.created_at_ms, + ], + )?; + Ok(c) + } + + pub fn list_contacts(&self, group_id: Option) -> StoreResult> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, group_id, name, notes, created_at_ms \ + FROM contacts WHERE group_id IS ?1 \ + ORDER BY name COLLATE NOCASE ASC", + )?; + let g = group_id.map(|g| g.to_string()); + let rows = stmt.query_map(params![g], row_to_contact)?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn delete_contact(&self, id: ContactId) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM contacts WHERE id = ?1", params![id.to_string()])?; + Ok(()) + } + + // ----------------------------------------------------------------- + // Charts + // ----------------------------------------------------------------- + + pub fn create_chart( + &self, + contact_id: ContactId, + kind: ChartKind, + label: &str, + birth: &StoredBirthData, + config: &StoredChartConfig, + related_chart_id: Option, + ) -> StoreResult { + let chart = Chart { + id: ChartId::new(), + contact_id, + kind, + label: label.into(), + birth_data: birth.clone(), + config: config.clone(), + related_chart_id, + created_at_ms: now_ms(), + }; + chart.validate()?; + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO charts \ + (id, contact_id, kind, label, birth_data_json, config_json, \ + related_chart_id, created_at_ms) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + chart.id.to_string(), + chart.contact_id.to_string(), + serde_json::to_string(&chart.kind)?, + chart.label, + serde_json::to_string(&chart.birth_data)?, + serde_json::to_string(&chart.config)?, + chart.related_chart_id.map(|c| c.to_string()), + chart.created_at_ms, + ], + )?; + Ok(chart) + } + + pub fn list_charts(&self, contact_id: ContactId) -> StoreResult> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, contact_id, kind, label, birth_data_json, config_json, \ + related_chart_id, created_at_ms \ + FROM charts WHERE contact_id = ?1 \ + ORDER BY created_at_ms ASC", + )?; + let rows = stmt.query_map(params![contact_id.to_string()], row_to_chart)?; + rows.collect::, _>>() + .map_err(StoreError::from) + .and_then(|v| v.into_iter().collect::>>()) + } + + pub fn get_chart(&self, id: ChartId) -> StoreResult { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, contact_id, kind, label, birth_data_json, config_json, \ + related_chart_id, created_at_ms \ + FROM charts WHERE id = ?1", + )?; + let chart = stmt + .query_row(params![id.to_string()], row_to_chart) + .optional()?; + match chart { + Some(Ok(c)) => Ok(c), + Some(Err(e)) => Err(e), + None => Err(StoreError::NotFound(format!("chart {}", id))), + } + } + + pub fn delete_chart(&self, id: ChartId) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM charts WHERE id = ?1", params![id.to_string()])?; + Ok(()) + } + + // ----------------------------------------------------------------- + // Module state + // ----------------------------------------------------------------- + + pub fn upsert_module_state(&self, state: &ModuleState) -> StoreResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO module_state (chart_id, module_id, enabled, config_json) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(chart_id, module_id) DO UPDATE SET \ + enabled = excluded.enabled, \ + config_json = excluded.config_json", + params![ + state.chart_id.to_string(), + state.module_id, + state.enabled as i32, + state.config.to_string(), + ], + )?; + Ok(()) + } + + pub fn list_module_states(&self, chart_id: ChartId) -> StoreResult> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT chart_id, module_id, enabled, config_json \ + FROM module_state WHERE chart_id = ?1", + )?; + let rows = stmt.query_map(params![chart_id.to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i32>(2)?, + row.get::<_, String>(3)?, + )) + })?; + let mut out = Vec::new(); + for r in rows { + let (chart_str, module_id, enabled, config_str) = r?; + out.push(ModuleState { + chart_id: chart_str + .parse() + .map_err(|e: ulid::DecodeError| StoreError::UlidDecode(e))?, + module_id, + enabled: enabled != 0, + config: serde_json::from_str(&config_str).unwrap_or(serde_json::Value::Null), + }); + } + Ok(out) + } + + // ----------------------------------------------------------------- + // Recursive descent: charts under a group/contact (para thumbnails) + // ----------------------------------------------------------------- + + /// Devuelve todas las cartas que descienden de un Group (incluyendo + /// los Contacts de sub-groups recursivamente). + pub fn charts_under_group(&self, root: GroupId) -> StoreResult> { + let conn = self.conn.lock().unwrap(); + // CTE recursivo para listar todos los descendientes del group. + let mut stmt = conn.prepare( + "WITH RECURSIVE descendants(id) AS ( \ + SELECT ?1 \ + UNION ALL \ + SELECT g.id FROM groups g JOIN descendants d ON g.parent_id = d.id \ + ) \ + SELECT c.id, c.contact_id, c.kind, c.label, c.birth_data_json, c.config_json, \ + c.related_chart_id, c.created_at_ms \ + FROM charts c \ + JOIN contacts ct ON ct.id = c.contact_id \ + WHERE ct.group_id IN descendants \ + ORDER BY c.created_at_ms ASC", + )?; + let rows = stmt.query_map(params![root.to_string()], row_to_chart)?; + let mut out = Vec::new(); + for r in rows { + out.push(r??); + } + Ok(out) + } +} + +// ===================================================================== +// SQL schema +// ===================================================================== + +const MIGRATION_V1: &str = r#" +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS groups ( + id TEXT PRIMARY KEY, + parent_id TEXT, + name TEXT NOT NULL, + description TEXT, + created_at_ms INTEGER NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(parent_id) REFERENCES groups(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_groups_parent ON groups(parent_id); + +CREATE TABLE IF NOT EXISTS contacts ( + id TEXT PRIMARY KEY, + group_id TEXT, + name TEXT NOT NULL, + notes TEXT, + created_at_ms INTEGER NOT NULL, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_contacts_group ON contacts(group_id); + +CREATE TABLE IF NOT EXISTS charts ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL, + kind TEXT NOT NULL, + label TEXT NOT NULL, + birth_data_json TEXT NOT NULL, + config_json TEXT NOT NULL, + related_chart_id TEXT, + created_at_ms INTEGER NOT NULL, + FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE, + FOREIGN KEY(related_chart_id) REFERENCES charts(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_charts_contact ON charts(contact_id); + +CREATE TABLE IF NOT EXISTS module_state ( + chart_id TEXT NOT NULL, + module_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + config_json TEXT NOT NULL DEFAULT '{}', + PRIMARY KEY(chart_id, module_id), + FOREIGN KEY(chart_id) REFERENCES charts(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +"#; + +// ===================================================================== +// Row decoders +// ===================================================================== + +fn row_to_group(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id_str: String = row.get(0)?; + let parent_id_str: Option = row.get(1)?; + Ok(Group { + id: id_str + .parse() + .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + parent_id: match parent_id_str { + Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| { + rusqlite::Error::ToSqlConversionFailure(Box::new(e)) + })?), + None => None, + }, + name: row.get(2)?, + description: row.get(3)?, + created_at_ms: row.get(4)?, + sort_order: row.get(5)?, + }) +} + +fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id_str: String = row.get(0)?; + let group_str: Option = row.get(1)?; + Ok(Contact { + id: id_str + .parse() + .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + group_id: match group_str { + Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| { + rusqlite::Error::ToSqlConversionFailure(Box::new(e)) + })?), + None => None, + }, + name: row.get(2)?, + notes: row.get(3)?, + created_at_ms: row.get(4)?, + }) +} + +fn row_to_chart(row: &rusqlite::Row<'_>) -> rusqlite::Result> { + // Doble-Result porque hay deserialización JSON adentro que rusqlite no + // sabe modelar. El caller la aplana. + let id_str: String = row.get(0)?; + let contact_str: String = row.get(1)?; + let kind_json: String = row.get(2)?; + let label: String = row.get(3)?; + let bd_json: String = row.get(4)?; + let cfg_json: String = row.get(5)?; + let related_str: Option = row.get(6)?; + let created_at_ms: i64 = row.get(7)?; + + Ok((|| -> StoreResult { + Ok(Chart { + id: id_str.parse()?, + contact_id: contact_str.parse()?, + kind: serde_json::from_str(&kind_json)?, + label, + birth_data: serde_json::from_str(&bd_json)?, + config: serde_json::from_str(&cfg_json)?, + related_chart_id: match related_str { + Some(s) => Some(s.parse()?), + None => None, + }, + created_at_ms, + }) + })()) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +// ===================================================================== +// Tests +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use tahuantinsuyu_model::{StoredBirthData, StoredChartConfig}; + + #[test] + fn open_and_migrate() { + let s = Store::in_memory().unwrap(); + let groups = s.list_groups(None).unwrap(); + assert!(groups.is_empty()); + } + + #[test] + fn full_hierarchy_roundtrip() { + let s = Store::in_memory().unwrap(); + let g = s.create_group(None, "Familia", None).unwrap(); + let c = s.create_contact(Some(g.id), "Sergio", None).unwrap(); + let chart = s + .create_chart( + c.id, + ChartKind::Natal, + "Natal", + &StoredBirthData { + year: 1987, + month: 3, + day: 14, + hour: 5, + minute: 22, + second: 0.0, + tz_offset_minutes: -240, + latitude_deg: 10.4806, + longitude_deg: -66.9036, + altitude_m: 900.0, + time_certainty: Default::default(), + subject_name: Some("Sergio".into()), + birthplace_label: Some("Caracas".into()), + }, + &StoredChartConfig::default(), + None, + ) + .unwrap(); + assert_eq!(s.get_chart(chart.id).unwrap().label, "Natal"); + + let under = s.charts_under_group(g.id).unwrap(); + assert_eq!(under.len(), 1); + assert_eq!(under[0].id, chart.id); + } +} diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/Cargo.toml new file mode 100644 index 0000000..e13305f --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "tahuantinsuyu-theme" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — paleta astrológica (elementos, planetas, signos) + presets dark/light místicos sobre yahweh-theme." + +[dependencies] +gpui = { workspace = true } +yahweh-theme = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs new file mode 100644 index 0000000..b32dd10 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs @@ -0,0 +1,295 @@ +//! `tahuantinsuyu-theme` — paleta simbólica + presets místicos. +//! +//! Una capa fina sobre [`yahweh_theme::Theme`]: el theme base aporta los +//! slots de panel/foreground/accent; nosotros agregamos paletas +//! semánticas para los elementos (fuego/tierra/aire/agua), los modos +//! (cardinal/fijo/mutable), los planetas y los aspectos. +//! +//! El canvas pide colores por símbolo (`palette.element(Element::Fire)`), +//! nunca hex directos. Así una sola tabla controla tanto el dark como el +//! light, y cambiar la paleta no requiere tocar el render. + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +use gpui::{Hsla, hsla}; + +// ===================================================================== +// Símbolos +// ===================================================================== + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Element { + Fire, + Earth, + Air, + Water, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Modality { + Cardinal, + Fixed, + Mutable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Planet { + Sun, + Moon, + Mercury, + Venus, + Mars, + Jupiter, + Saturn, + Uranus, + Neptune, + Pluto, + Chiron, + NorthNode, + SouthNode, + Lilith, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AspectKind { + Conjunction, + Sextile, + Square, + Trine, + Opposition, + Quincunx, + Semisextile, + Semisquare, + Sesquisquare, + Quintile, + Biquintile, +} + +// ===================================================================== +// Paleta +// ===================================================================== + +/// Paleta completa de símbolos astrológicos resuelta a colores HSLA. Las +/// dos variantes (`dark` / `light`) comparten estructura — el canvas +/// elige según `yahweh_theme::Theme::is_dark`. +#[derive(Debug, Clone)] +pub struct AstroPalette { + pub is_dark: bool, + + pub fire: Hsla, + pub earth: Hsla, + pub air: Hsla, + pub water: Hsla, + + pub cardinal: Hsla, + pub fixed: Hsla, + pub mutable: Hsla, + + pub sun: Hsla, + pub moon: Hsla, + pub mercury: Hsla, + pub venus: Hsla, + pub mars: Hsla, + pub jupiter: Hsla, + pub saturn: Hsla, + pub uranus: Hsla, + pub neptune: Hsla, + pub pluto: Hsla, + pub chiron: Hsla, + pub north_node: Hsla, + pub south_node: Hsla, + pub lilith: Hsla, + + pub conjunction: Hsla, + pub sextile: Hsla, + pub square: Hsla, + pub trine: Hsla, + pub opposition: Hsla, + pub minor_aspect: Hsla, + + /// Color del dial zodiacal (anillo exterior). + pub dial_ring: Hsla, + /// Cusps de casas. + pub house_cusp: Hsla, + /// Resaltado del ascendente / MC. + pub angle_highlight: Hsla, +} + +impl AstroPalette { + /// Variante oscura — calibrada para sentirse cálida y mística sin + /// caer en saturación de carnaval. Las cusps quedan apenas más + /// claras que el fondo, los planetas tienen luminancia media-alta + /// para destacar sin glow falso. + pub fn dark() -> Self { + Self { + is_dark: true, + + // Elementos — saturación alta + luminancia media. Familiares + // al símbolo pero suaves para coexistir. + fire: hsla(11.0 / 360.0, 0.78, 0.58, 1.0), + earth: hsla(95.0 / 360.0, 0.40, 0.48, 1.0), + air: hsla(48.0 / 360.0, 0.72, 0.66, 1.0), + water: hsla(210.0 / 360.0, 0.68, 0.58, 1.0), + + cardinal: hsla(340.0 / 360.0, 0.55, 0.62, 1.0), + fixed: hsla(258.0 / 360.0, 0.48, 0.58, 1.0), + mutable: hsla(170.0 / 360.0, 0.42, 0.55, 1.0), + + sun: hsla(45.0 / 360.0, 0.92, 0.62, 1.0), + moon: hsla(220.0 / 360.0, 0.25, 0.85, 1.0), + mercury: hsla(140.0 / 360.0, 0.40, 0.62, 1.0), + venus: hsla(330.0 / 360.0, 0.55, 0.70, 1.0), + mars: hsla(8.0 / 360.0, 0.78, 0.55, 1.0), + jupiter: hsla(38.0 / 360.0, 0.72, 0.62, 1.0), + saturn: hsla(28.0 / 360.0, 0.20, 0.50, 1.0), + uranus: hsla(195.0 / 360.0, 0.65, 0.62, 1.0), + neptune: hsla(225.0 / 360.0, 0.55, 0.66, 1.0), + pluto: hsla(280.0 / 360.0, 0.40, 0.45, 1.0), + chiron: hsla(75.0 / 360.0, 0.30, 0.55, 1.0), + north_node: hsla(35.0 / 360.0, 0.35, 0.70, 1.0), + south_node: hsla(35.0 / 360.0, 0.20, 0.45, 1.0), + lilith: hsla(310.0 / 360.0, 0.45, 0.40, 1.0), + + conjunction: hsla(50.0 / 360.0, 0.65, 0.70, 0.85), + sextile: hsla(195.0 / 360.0, 0.60, 0.62, 0.75), + square: hsla(8.0 / 360.0, 0.75, 0.58, 0.85), + trine: hsla(140.0 / 360.0, 0.55, 0.55, 0.80), + opposition: hsla(280.0 / 360.0, 0.55, 0.62, 0.85), + minor_aspect: hsla(220.0 / 360.0, 0.20, 0.55, 0.55), + + dial_ring: hsla(40.0 / 360.0, 0.18, 0.78, 0.85), + house_cusp: hsla(40.0 / 360.0, 0.12, 0.55, 0.60), + angle_highlight: hsla(50.0 / 360.0, 0.95, 0.65, 1.0), + } + } + + /// Variante clara — desaturada y con luminancias bajas para que los + /// símbolos no compitan con el fondo blanco. Pensada para imprimir. + pub fn light() -> Self { + Self { + is_dark: false, + + fire: hsla(11.0 / 360.0, 0.65, 0.42, 1.0), + earth: hsla(95.0 / 360.0, 0.45, 0.30, 1.0), + air: hsla(48.0 / 360.0, 0.55, 0.42, 1.0), + water: hsla(210.0 / 360.0, 0.60, 0.38, 1.0), + + cardinal: hsla(340.0 / 360.0, 0.55, 0.42, 1.0), + fixed: hsla(258.0 / 360.0, 0.45, 0.40, 1.0), + mutable: hsla(170.0 / 360.0, 0.42, 0.35, 1.0), + + sun: hsla(38.0 / 360.0, 0.85, 0.45, 1.0), + moon: hsla(220.0 / 360.0, 0.22, 0.45, 1.0), + mercury: hsla(140.0 / 360.0, 0.45, 0.36, 1.0), + venus: hsla(330.0 / 360.0, 0.55, 0.45, 1.0), + mars: hsla(8.0 / 360.0, 0.75, 0.40, 1.0), + jupiter: hsla(38.0 / 360.0, 0.72, 0.42, 1.0), + saturn: hsla(28.0 / 360.0, 0.25, 0.30, 1.0), + uranus: hsla(195.0 / 360.0, 0.65, 0.40, 1.0), + neptune: hsla(225.0 / 360.0, 0.55, 0.42, 1.0), + pluto: hsla(280.0 / 360.0, 0.45, 0.30, 1.0), + chiron: hsla(75.0 / 360.0, 0.32, 0.35, 1.0), + north_node: hsla(35.0 / 360.0, 0.45, 0.45, 1.0), + south_node: hsla(35.0 / 360.0, 0.20, 0.30, 1.0), + lilith: hsla(310.0 / 360.0, 0.50, 0.30, 1.0), + + conjunction: hsla(45.0 / 360.0, 0.65, 0.40, 0.85), + sextile: hsla(195.0 / 360.0, 0.60, 0.38, 0.75), + square: hsla(8.0 / 360.0, 0.75, 0.40, 0.85), + trine: hsla(140.0 / 360.0, 0.55, 0.35, 0.80), + opposition: hsla(280.0 / 360.0, 0.55, 0.42, 0.85), + minor_aspect: hsla(220.0 / 360.0, 0.20, 0.45, 0.55), + + dial_ring: hsla(40.0 / 360.0, 0.18, 0.32, 0.90), + house_cusp: hsla(40.0 / 360.0, 0.10, 0.45, 0.50), + angle_highlight: hsla(45.0 / 360.0, 0.85, 0.40, 1.0), + } + } + + pub fn for_theme(theme: &yahweh_theme::Theme) -> Self { + if theme.is_dark { + Self::dark() + } else { + Self::light() + } + } + + pub fn element(&self, e: Element) -> Hsla { + match e { + Element::Fire => self.fire, + Element::Earth => self.earth, + Element::Air => self.air, + Element::Water => self.water, + } + } + + pub fn modality(&self, m: Modality) -> Hsla { + match m { + Modality::Cardinal => self.cardinal, + Modality::Fixed => self.fixed, + Modality::Mutable => self.mutable, + } + } + + pub fn planet(&self, p: Planet) -> Hsla { + match p { + Planet::Sun => self.sun, + Planet::Moon => self.moon, + Planet::Mercury => self.mercury, + Planet::Venus => self.venus, + Planet::Mars => self.mars, + Planet::Jupiter => self.jupiter, + Planet::Saturn => self.saturn, + Planet::Uranus => self.uranus, + Planet::Neptune => self.neptune, + Planet::Pluto => self.pluto, + Planet::Chiron => self.chiron, + Planet::NorthNode => self.north_node, + Planet::SouthNode => self.south_node, + Planet::Lilith => self.lilith, + } + } + + pub fn aspect(&self, a: AspectKind) -> Hsla { + match a { + AspectKind::Conjunction => self.conjunction, + AspectKind::Sextile => self.sextile, + AspectKind::Square => self.square, + AspectKind::Trine => self.trine, + AspectKind::Opposition => self.opposition, + _ => self.minor_aspect, + } + } +} + +/// Resuelve un símbolo zodiacal (string) a su elemento. +/// Ej. `"aries" → Fire`, `"taurus" → Earth`, … +pub fn element_for_sign(sign: &str) -> Option { + Some(match sign.to_ascii_lowercase().as_str() { + "aries" | "leo" | "sagittarius" => Element::Fire, + "taurus" | "virgo" | "capricorn" => Element::Earth, + "gemini" | "libra" | "aquarius" => Element::Air, + "cancer" | "scorpio" | "pisces" => Element::Water, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn element_lookup() { + assert_eq!(element_for_sign("aries"), Some(Element::Fire)); + assert_eq!(element_for_sign("CAPRICORN"), Some(Element::Earth)); + assert_eq!(element_for_sign("zod"), None); + } + + #[test] + fn palette_indexes() { + let p = AstroPalette::dark(); + assert_eq!(p.planet(Planet::Sun), p.sun); + assert_eq!(p.element(Element::Water), p.water); + } +} diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml new file mode 100644 index 0000000..99fe9ac --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "tahuantinsuyu-tree" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — explorador izquierdo (Groups/Contacts/Charts) sobre yahweh-widget-tree." + +[dependencies] +tahuantinsuyu-model = { path = "../tahuantinsuyu-model" } +tahuantinsuyu-store = { path = "../tahuantinsuyu-store" } +yahweh-theme = { workspace = true } +yahweh-widget-tree = { workspace = true } +gpui = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs new file mode 100644 index 0000000..bb915bb --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs @@ -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:` → Group +//! - `c:` → Contact +//! - `h:` → 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, + expanded: HashSet, +} + +impl EventEmitter for TahuantinsuyuTree {} + +impl TahuantinsuyuTree { + pub fn new(store: Store, cx: &mut Context) -> 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) { + 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, depth: u32, out: &mut Vec) { + 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, depth: u32, out: &mut Vec) { + 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) { + 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) { + 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 { + 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) -> impl IntoElement { + self.inner.clone() + } +}