feat(tahuantinsuyu): scaffolding del estudio astrológico (10 crates + ventana 3-panes)
Módulo nuevo `modules/tahuantinsuyu/` con 9 crates reusables + app `apps/tahuantinsuyu` ejecutable que abre la ventana del explorador y coordina los widgets: - tahuantinsuyu-card: Card Brahman + spawn_sidecar (flows chart-request/chart-result). - tahuantinsuyu-model: tipos agnósticos (Group/Contact/Chart, StoredBirthData, StoredChartConfig, ChartKind, TreeSelection). - tahuantinsuyu-store: persistencia SQLite (rusqlite) con migración v1, CRUD por entidad y descenso recursivo `charts_under_group`. - tahuantinsuyu-engine: bridge agnóstico al canvas vía `RenderModel` (Layer/Glyph/Geometry). Feature `eternal-bridge` (off por default) reservada para enchufar eternal-astrology desde ~/eternal. - tahuantinsuyu-modules: registry de módulos pluggables (Module trait + Control schema) con `NatalModule` placeholder. - tahuantinsuyu-theme: AstroPalette (elementos / modos / planetas / aspectos) con variantes dark + light sobre yahweh-theme. - tahuantinsuyu-canvas: widget GPUI con CanvasState (Empty / Wheel / Thumbnails). Render placeholder hasta cablear la rueda real. - tahuantinsuyu-tree: explorador izquierdo sobre yahweh-widget-tree, prefijos g:/c:/h: para Group/Contact/Chart. - tahuantinsuyu-panel: control panel inferior que lee Controls de los módulos del registry y los pinta. - apps/tahuantinsuyu: binario `tahuantinsuyu` (launch_app-style) con Shell coordinador (tree↔canvas↔panel), DB en $XDG_DATA_HOME. Workspace Cargo.toml actualizado con los 10 miembros. `cargo check` verde, tests unitarios verdes (model/store/engine/modules/theme/card). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user