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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-16 01:06:03 +00:00
parent e8f97b50cb
commit c48638fe87
23 changed files with 3256 additions and 0 deletions
@@ -0,0 +1,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 }
@@ -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<RenderModel> },
/// Grilla de thumbnails para un Group o Contact con varias cartas.
Thumbnails {
scope: ThumbnailScope,
items: Vec<ThumbnailItem>,
},
}
#[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<SharedString>,
/// `Some` si ya hay un render-mock disponible. `None` = lazy.
pub preview: Option<RenderModel>,
}
/// 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<String>,
}
// =====================================================================
// Widget
// =====================================================================
pub struct AstrologyCanvas {
state: CanvasState,
}
impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
impl AstrologyCanvas {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, 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>) {
self.state.mode = mode;
cx.notify();
}
pub fn toggle_module(&mut self, module_id: &str, cx: &mut Context<Self>) {
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>) {
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<Self>) -> 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
}
@@ -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 }
@@ -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");
}
}
@@ -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"]
@@ -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<Layer>,
/// 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<Glyph>,
}
#[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<f32>,
},
/// Conjunto de líneas (aspectos). Cada par = `(from_deg, to_deg)`.
Lines(Vec<LineSeg>),
/// Puntos sueltos con marcadores (lots, fixed stars).
Points(Vec<PointMark>),
}
#[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<String>,
/// `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<u8>,
}
// =====================================================================
// 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<RenderModel, EngineError> {
// 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);
}
}
@@ -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 }
@@ -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<Self, Self::Err> {
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<GroupId>,
pub name: String,
#[serde(default)]
pub description: Option<String>,
/// 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<GroupId>,
pub name: String,
#[serde(default)]
pub notes: Option<String>,
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<String>,
#[serde(default)]
pub birthplace_label: Option<String>,
}
#[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<String>,
/// 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<String>,
#[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<String>,
}
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<String> {
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<ChartId>,
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(())
}
}
@@ -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 }
@@ -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<Control> {
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<Layer>;
}
// =====================================================================
// Controls expuestos al panel
// =====================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Control {
Toggle {
key: String,
label: String,
default: bool,
hotkey: Option<String>,
},
Slider {
key: String,
label: String,
min: f64,
max: f64,
step: f64,
default: f64,
},
Select {
key: String,
label: String,
options: Vec<SelectOption>,
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<Box<dyn Module>>,
}
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<dyn Module>) {
self.modules.push(m);
}
pub fn all(&self) -> &[Box<dyn Module>] {
&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<Control> {
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<Layer> {
// 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());
}
}
@@ -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 }
@@ -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<ChartKind>,
}
impl EventEmitter<PanelEvent> for ControlPanel {}
impl ControlPanel {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self { active_kind: None }
}
pub fn set_active_kind(&mut self, kind: Option<ChartKind>, cx: &mut Context<Self>) {
self.active_kind = kind;
cx.notify();
}
}
impl Render for ControlPanel {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> 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())),
),
}
}
@@ -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 }
@@ -0,0 +1,557 @@
//! `tahuantinsuyu-store` — persistencia SQLite del estudio astrológico.
//!
//! Una sola conexión `rusqlite` envuelta en `Arc<Mutex>` 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<T> = Result<T, StoreError>;
/// 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<Mutex<Connection>>,
}
impl Store {
/// Abre (o crea) un archivo SQLite y corre las migraciones.
pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
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<Self> {
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<GroupId>,
name: &str,
description: Option<&str>,
) -> StoreResult<Group> {
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<GroupId>) -> StoreResult<Vec<Group>> {
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::<Result<Vec<_>, _>>().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<GroupId>,
name: &str,
notes: Option<&str>,
) -> StoreResult<Contact> {
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<GroupId>) -> StoreResult<Vec<Contact>> {
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::<Result<Vec<_>, _>>().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<ChartId>,
) -> StoreResult<Chart> {
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<Vec<Chart>> {
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::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
.and_then(|v| v.into_iter().collect::<StoreResult<Vec<_>>>())
}
pub fn get_chart(&self, id: ChartId) -> StoreResult<Chart> {
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<Vec<ModuleState>> {
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<Vec<Chart>> {
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<Group> {
let id_str: String = row.get(0)?;
let parent_id_str: Option<String> = 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<Contact> {
let id_str: String = row.get(0)?;
let group_str: Option<String> = 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<StoreResult<Chart>> {
// 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<String> = row.get(6)?;
let created_at_ms: i64 = row.get(7)?;
Ok((|| -> StoreResult<Chart> {
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);
}
}
@@ -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 }
@@ -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<Element> {
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);
}
}
@@ -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 }
@@ -0,0 +1,192 @@
//! `tahuantinsuyu-tree` — explorador jerárquico Groups → Contacts → Charts.
//!
//! Envuelve [`yahweh_widget_tree::TreeView`] con la lógica de dominio
//! propia de Tahuantinsuyu. Los `RowId` codifican el tipo del item con
//! prefijo:
//!
//! - `g:<ulid>` → Group
//! - `c:<ulid>` → Contact
//! - `h:<ulid>` → Chart
//!
//! El host (la app) se suscribe a [`TreeEvent`] y traduce a `AppEvent`
//! del bus de yahweh para que el canvas/panel reaccionen.
//!
//! Esta fase 1 trae el wrapper + el armado de filas; el CRUD UX
//! (drag-to-nest, rename inline, menú contextual) llega con la fase 2.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::HashSet;
use gpui::{Context, Entity, EventEmitter, IntoElement, Render, Window, prelude::*};
use tahuantinsuyu_model::{ContactId, GroupId, TreeSelection};
use tahuantinsuyu_store::Store;
use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, TreeView};
const PREFIX_GROUP: &str = "g:";
const PREFIX_CONTACT: &str = "c:";
const PREFIX_CHART: &str = "h:";
// =====================================================================
// Eventos públicos
// =====================================================================
#[derive(Clone, Debug)]
pub enum TreeEvent {
/// El usuario activó (single click) un item.
Selected(TreeSelection),
/// El usuario abrió (doble click) un item — la app decide qué hacer
/// (en general, abrir la carta en el canvas).
Opened(TreeSelection),
}
// =====================================================================
// Widget
// =====================================================================
pub struct TahuantinsuyuTree {
store: Store,
inner: Entity<TreeView>,
expanded: HashSet<String>,
}
impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
impl TahuantinsuyuTree {
pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
let inner = cx.new(|cx| TreeView::new("tahuantinsuyu-tree", cx));
cx.subscribe(&inner, |this: &mut Self, _, ev, cx| {
this.on_inner(ev, cx);
})
.detach();
let mut me = Self {
store,
inner,
expanded: HashSet::new(),
};
me.refresh(cx);
me
}
/// Re-lee la jerarquía desde la store y empuja al TreeView. Llamar
/// después de crear/borrar items.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
let mut rows = Vec::new();
self.append_groups(None, 0, &mut rows);
self.append_contacts(None, 0, &mut rows);
self.inner
.update(cx, |t, cx| t.set_rows(rows, cx));
}
fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
let groups = match self.store.list_groups(parent) {
Ok(v) => v,
Err(_) => return,
};
for g in groups {
let id_str = format!("{}{}", PREFIX_GROUP, g.id);
let expanded = self.expanded.contains(&id_str);
out.push(TreeRow {
id: RowId::new(id_str.clone()),
label: g.name.clone(),
depth,
kind: RowKind::Branch,
expanded,
icon: Some("📁".into()),
});
if expanded {
self.append_groups(Some(g.id), depth + 1, out);
self.append_contacts(Some(g.id), depth + 1, out);
}
}
}
fn append_contacts(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
let contacts = match self.store.list_contacts(parent) {
Ok(v) => v,
Err(_) => return,
};
for c in contacts {
let id_str = format!("{}{}", PREFIX_CONTACT, c.id);
let expanded = self.expanded.contains(&id_str);
out.push(TreeRow {
id: RowId::new(id_str.clone()),
label: c.name.clone(),
depth,
kind: RowKind::Branch,
expanded,
icon: Some("🜨".into()),
});
if expanded {
self.append_charts(c.id, depth + 1, out);
}
}
}
fn append_charts(&self, contact: ContactId, depth: u32, out: &mut Vec<TreeRow>) {
let charts = match self.store.list_charts(contact) {
Ok(v) => v,
Err(_) => return,
};
for h in charts {
let id_str = format!("{}{}", PREFIX_CHART, h.id);
out.push(TreeRow {
id: RowId::new(id_str),
label: h.label.clone(),
depth,
kind: RowKind::Leaf,
expanded: false,
icon: Some("".into()),
});
}
}
fn on_inner(&mut self, ev: &InnerTreeEvent, cx: &mut Context<Self>) {
match ev {
InnerTreeEvent::ChevronToggled(id) => {
let s = id.as_str().to_string();
if !self.expanded.remove(&s) {
self.expanded.insert(s);
}
self.refresh(cx);
}
InnerTreeEvent::RowClicked(id) => {
if let Some(sel) = parse_row(id) {
cx.emit(TreeEvent::Selected(sel));
}
}
InnerTreeEvent::RowDoubleClicked(id) => {
if let Some(sel) = parse_row(id) {
cx.emit(TreeEvent::Opened(sel));
}
}
InnerTreeEvent::ContextMenuRequested { .. } => {
// Fase 2: menú contextual para crear/renombrar/borrar.
}
InnerTreeEvent::ActiveChanged(_) => {}
}
}
}
fn parse_row(id: &RowId) -> Option<TreeSelection> {
let s = id.as_str();
if let Some(rest) = s.strip_prefix(PREFIX_GROUP) {
return rest.parse().ok().map(TreeSelection::Group);
}
if let Some(rest) = s.strip_prefix(PREFIX_CONTACT) {
return rest.parse().ok().map(TreeSelection::Contact);
}
if let Some(rest) = s.strip_prefix(PREFIX_CHART) {
return rest.parse().ok().map(TreeSelection::Chart);
}
None
}
impl Render for TahuantinsuyuTree {
fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.inner.clone()
}
}