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:
Generated
+242
@@ -1584,6 +1584,15 @@ dependencies = [
|
|||||||
"unicode-security",
|
"unicode-security",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "celestial-eop-data"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0db7627f7cbdcaed155e66503e07025e10701a3566bc211a85e35b918bc40812"
|
||||||
|
dependencies = [
|
||||||
|
"zstd",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cexpr"
|
name = "cexpr"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -2513,6 +2522,15 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "directories"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys 0.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
@@ -3239,6 +3257,88 @@ dependencies = [
|
|||||||
"svg_fmt",
|
"svg_fmt",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "eternal-astrology"
|
||||||
|
version = "0.1.1-alpha.2"
|
||||||
|
dependencies = [
|
||||||
|
"eternal-core",
|
||||||
|
"eternal-ephemeris",
|
||||||
|
"eternal-sky",
|
||||||
|
"eternal-time",
|
||||||
|
"eternal-validation",
|
||||||
|
"libm",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "eternal-coords"
|
||||||
|
version = "0.1.1-alpha.2"
|
||||||
|
dependencies = [
|
||||||
|
"celestial-eop-data",
|
||||||
|
"eternal-core",
|
||||||
|
"eternal-time",
|
||||||
|
"libm",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "eternal-core"
|
||||||
|
version = "0.1.1-alpha.2"
|
||||||
|
dependencies = [
|
||||||
|
"libm",
|
||||||
|
"once_cell",
|
||||||
|
"regex",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "eternal-ephemeris"
|
||||||
|
version = "0.1.1-alpha.2"
|
||||||
|
dependencies = [
|
||||||
|
"eternal-coords",
|
||||||
|
"eternal-core",
|
||||||
|
"eternal-time",
|
||||||
|
"libm",
|
||||||
|
"memmap2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "eternal-sky"
|
||||||
|
version = "0.1.1-alpha.2"
|
||||||
|
dependencies = [
|
||||||
|
"eternal-coords",
|
||||||
|
"eternal-core",
|
||||||
|
"eternal-ephemeris",
|
||||||
|
"eternal-time",
|
||||||
|
"eternal-validation",
|
||||||
|
"libm",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "eternal-time"
|
||||||
|
version = "0.1.1-alpha.2"
|
||||||
|
dependencies = [
|
||||||
|
"eternal-core",
|
||||||
|
"libm",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "eternal-validation"
|
||||||
|
version = "0.1.1-alpha.2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"eternal-coords",
|
||||||
|
"eternal-core",
|
||||||
|
"eternal-ephemeris",
|
||||||
|
"eternal-time",
|
||||||
|
"libm",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "euclid"
|
name = "euclid"
|
||||||
version = "0.22.14"
|
version = "0.22.14"
|
||||||
@@ -10800,6 +10900,120 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"directories",
|
||||||
|
"gpui",
|
||||||
|
"tahuantinsuyu-canvas",
|
||||||
|
"tahuantinsuyu-card",
|
||||||
|
"tahuantinsuyu-engine",
|
||||||
|
"tahuantinsuyu-model",
|
||||||
|
"tahuantinsuyu-modules",
|
||||||
|
"tahuantinsuyu-panel",
|
||||||
|
"tahuantinsuyu-store",
|
||||||
|
"tahuantinsuyu-theme",
|
||||||
|
"tahuantinsuyu-tree",
|
||||||
|
"yahweh-bus",
|
||||||
|
"yahweh-theme",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-canvas"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"gpui",
|
||||||
|
"tahuantinsuyu-engine",
|
||||||
|
"tahuantinsuyu-model",
|
||||||
|
"tahuantinsuyu-modules",
|
||||||
|
"tahuantinsuyu-theme",
|
||||||
|
"yahweh-theme",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-card"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"brahman-card",
|
||||||
|
"brahman-sidecar",
|
||||||
|
"ulid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-engine"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"eternal-astrology",
|
||||||
|
"eternal-sky",
|
||||||
|
"serde",
|
||||||
|
"tahuantinsuyu-model",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-model"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ulid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-modules"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tahuantinsuyu-engine",
|
||||||
|
"tahuantinsuyu-model",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-panel"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"gpui",
|
||||||
|
"serde_json",
|
||||||
|
"tahuantinsuyu-model",
|
||||||
|
"tahuantinsuyu-modules",
|
||||||
|
"tahuantinsuyu-theme",
|
||||||
|
"yahweh-theme",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-store"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tahuantinsuyu-model",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ulid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-theme"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"gpui",
|
||||||
|
"yahweh-theme",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-tree"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"gpui",
|
||||||
|
"tahuantinsuyu-model",
|
||||||
|
"tahuantinsuyu-store",
|
||||||
|
"yahweh-theme",
|
||||||
|
"yahweh-widget-tree",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "take-until"
|
name = "take-until"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -14011,6 +14225,34 @@ version = "1.0.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-safe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-safe"
|
||||||
|
version = "7.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||||
|
dependencies = [
|
||||||
|
"zstd-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstd-sys"
|
||||||
|
version = "2.0.16+zstd.1.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
+14
@@ -133,6 +133,19 @@ members = [
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
"crates/modules/barra/barra-web",
|
"crates/modules/barra/barra-web",
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# modules/tahuantinsuyu/ — estudio de astrología profesional
|
||||||
|
# ============================================================
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-card",
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-model",
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-store",
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-engine",
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-modules",
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-theme",
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-canvas",
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-tree",
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-panel",
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
|
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -156,6 +169,7 @@ members = [
|
|||||||
"crates/apps/lapaloma-stream-demo",
|
"crates/apps/lapaloma-stream-demo",
|
||||||
"crates/apps/lapaloma-phosphor-demo",
|
"crates/apps/lapaloma-phosphor-demo",
|
||||||
"crates/apps/lapaloma-financial-demo",
|
"crates/apps/lapaloma-financial-demo",
|
||||||
|
"crates/apps/tahuantinsuyu",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "tahuantinsuyu"
|
||||||
|
version = { workspace = true }
|
||||||
|
edition = { workspace = true }
|
||||||
|
license = { workspace = true }
|
||||||
|
description = "Tahuantinsuyu — estudio profesional de astrología. Tree + canvas + panel sobre yahweh + eternal-astrology."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tahuantinsuyu-card = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-card" }
|
||||||
|
tahuantinsuyu-canvas = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-canvas" }
|
||||||
|
tahuantinsuyu-engine = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-engine" }
|
||||||
|
tahuantinsuyu-model = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-model" }
|
||||||
|
tahuantinsuyu-modules = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-modules" }
|
||||||
|
tahuantinsuyu-panel = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-panel" }
|
||||||
|
tahuantinsuyu-store = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-store" }
|
||||||
|
tahuantinsuyu-theme = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-theme" }
|
||||||
|
tahuantinsuyu-tree = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-tree" }
|
||||||
|
|
||||||
|
yahweh-bus = { workspace = true }
|
||||||
|
yahweh-theme = { workspace = true }
|
||||||
|
gpui = { workspace = true }
|
||||||
|
directories = { workspace = true }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "tahuantinsuyu"
|
||||||
|
path = "src/main.rs"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! Tahuantinsuyu — binario standalone.
|
||||||
|
//!
|
||||||
|
//! Boot:
|
||||||
|
//! 1. `tahuantinsuyu_card::spawn_sidecar()` se presenta al Init brahman
|
||||||
|
//! (fire-and-forget; si no hay Init, la app sigue standalone).
|
||||||
|
//! 2. Abre la DB SQLite en `$XDG_DATA_HOME/tahuantinsuyu/charts.db`
|
||||||
|
//! (fallback a `~/.local/share/tahuantinsuyu/charts.db`).
|
||||||
|
//! 3. Levanta GPUI con [`yahweh_theme::Theme::install_default`].
|
||||||
|
//! 4. Compone el shell: [`Shell`] dueño del tree (izq), canvas (centro)
|
||||||
|
//! y panel (abajo). Cablea las suscripciones cross-widget.
|
||||||
|
//!
|
||||||
|
//! ## Layout
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ┌───────────┬────────────────────────────────────────┐
|
||||||
|
//! │ │ │
|
||||||
|
//! │ tree │ canvas │
|
||||||
|
//! │ (groups, │ (rueda / thumbnails) │
|
||||||
|
//! │ contacts,│ │
|
||||||
|
//! │ charts) │ │
|
||||||
|
//! │ │ │
|
||||||
|
//! ├───────────┴────────────────────────────────────────┤
|
||||||
|
//! │ control panel (módulos) │
|
||||||
|
//! └─────────────────────────────────────────────────────┘
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod shell;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, Application, Bounds, SharedString, TitlebarOptions, WindowBounds,
|
||||||
|
WindowOptions, px, size,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tahuantinsuyu_store::Store;
|
||||||
|
use yahweh_theme::Theme;
|
||||||
|
|
||||||
|
use crate::shell::Shell;
|
||||||
|
|
||||||
|
const DB_FILENAME: &str = "charts.db";
|
||||||
|
const APP_TITLE: &str = "Tahuantinsuyu";
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Sidecar brahman primero — si el Init está corriendo, nos presentamos.
|
||||||
|
tahuantinsuyu_card::spawn_sidecar();
|
||||||
|
|
||||||
|
// DB en directorio de datos del usuario.
|
||||||
|
let db_path = resolve_db_path();
|
||||||
|
let store = match Store::open(&db_path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"[tahuantinsuyu] no se pudo abrir la DB en {:?}: {} — usando memoria",
|
||||||
|
db_path, e
|
||||||
|
);
|
||||||
|
Store::in_memory().expect("in-memory store")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Application::new().run(move |cx: &mut App| {
|
||||||
|
Theme::install_default(cx);
|
||||||
|
|
||||||
|
let bounds = Bounds::centered(None, size(px(1400.0), px(900.0)), cx);
|
||||||
|
cx.open_window(
|
||||||
|
WindowOptions {
|
||||||
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||||
|
titlebar: Some(TitlebarOptions {
|
||||||
|
title: Some(SharedString::from(APP_TITLE)),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
move |_w, cx| cx.new(|cx| Shell::new(store.clone(), cx)),
|
||||||
|
)
|
||||||
|
.expect("open window");
|
||||||
|
cx.activate(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_db_path() -> PathBuf {
|
||||||
|
if let Some(dirs) = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu") {
|
||||||
|
let dir = dirs.data_dir().to_path_buf();
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
return dir.join(DB_FILENAME);
|
||||||
|
}
|
||||||
|
PathBuf::from(DB_FILENAME)
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! Shell — coordinador de los tres widgets.
|
||||||
|
//!
|
||||||
|
//! Es el "director de orquesta": dueño del tree, del canvas y del panel,
|
||||||
|
//! reenvía eventos entre ellos y aplica las mutaciones en la store.
|
||||||
|
//!
|
||||||
|
//! Flujo típico:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Tree.Selected(Chart) → Shell → Canvas.set_mode(Wheel)
|
||||||
|
//! → Panel.set_active_kind(chart.kind)
|
||||||
|
//!
|
||||||
|
//! Tree.Selected(Group) → Shell → Canvas.set_mode(Thumbnails{…})
|
||||||
|
//! → Panel.set_active_kind(None)
|
||||||
|
//!
|
||||||
|
//! Panel.ModuleToggled → Shell → Store.upsert_module_state
|
||||||
|
//! → Canvas.toggle_module
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Fase 1: las suscripciones están cableadas pero los handlers son
|
||||||
|
//! mínimos (logging + transición de modo). La pipeline real de cómputo
|
||||||
|
//! viene con la fase 3.
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div,
|
||||||
|
prelude::*, px,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tahuantinsuyu_canvas::{
|
||||||
|
AstrologyCanvas, CanvasMode, ThumbnailItem, ThumbnailScope,
|
||||||
|
};
|
||||||
|
use tahuantinsuyu_engine::compute_mock;
|
||||||
|
use tahuantinsuyu_model::TreeSelection;
|
||||||
|
use tahuantinsuyu_panel::{ControlPanel, PanelEvent};
|
||||||
|
use tahuantinsuyu_store::Store;
|
||||||
|
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
|
||||||
|
use yahweh_bus::AppBus;
|
||||||
|
use yahweh_theme::Theme;
|
||||||
|
|
||||||
|
const TREE_WIDTH: f32 = 280.0;
|
||||||
|
const PANEL_HEIGHT: f32 = 180.0;
|
||||||
|
|
||||||
|
pub struct Shell {
|
||||||
|
store: Store,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
bus: Entity<AppBus>,
|
||||||
|
tree: Entity<TahuantinsuyuTree>,
|
||||||
|
canvas: Entity<AstrologyCanvas>,
|
||||||
|
panel: Entity<ControlPanel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shell {
|
||||||
|
pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
|
||||||
|
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||||
|
|
||||||
|
let bus = cx.new(|_| AppBus);
|
||||||
|
let tree = cx.new(|cx| TahuantinsuyuTree::new(store.clone(), cx));
|
||||||
|
let canvas = cx.new(AstrologyCanvas::new);
|
||||||
|
let panel = cx.new(ControlPanel::new);
|
||||||
|
|
||||||
|
// Tree → Shell: aplicar selección al canvas/panel.
|
||||||
|
cx.subscribe(&tree, |this: &mut Self, _, ev: &TreeEvent, cx| {
|
||||||
|
this.on_tree_event(ev, cx);
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
// Panel → Shell: persistir y propagar al canvas.
|
||||||
|
cx.subscribe(&panel, |this: &mut Self, _, ev: &PanelEvent, cx| {
|
||||||
|
this.on_panel_event(ev, cx);
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
bus,
|
||||||
|
tree,
|
||||||
|
canvas,
|
||||||
|
panel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_tree_event(&mut self, ev: &TreeEvent, cx: &mut Context<Self>) {
|
||||||
|
let selection = match ev {
|
||||||
|
TreeEvent::Selected(s) => s,
|
||||||
|
TreeEvent::Opened(s) => s,
|
||||||
|
};
|
||||||
|
self.apply_selection(selection.clone(), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
|
||||||
|
match sel {
|
||||||
|
TreeSelection::Chart(id) => {
|
||||||
|
let chart = match self.store.get_chart(id) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[shell] get_chart {}: {}", id, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let kind = chart.kind;
|
||||||
|
let render = compute_mock(&chart);
|
||||||
|
self.canvas.update(cx, |c, cx| {
|
||||||
|
c.set_mode(
|
||||||
|
CanvasMode::Wheel {
|
||||||
|
render: Box::new(render),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
self.panel
|
||||||
|
.update(cx, |p, cx| p.set_active_kind(Some(kind), cx));
|
||||||
|
}
|
||||||
|
TreeSelection::Contact(id) => {
|
||||||
|
let charts = self.store.list_charts(id).unwrap_or_default();
|
||||||
|
let items: Vec<ThumbnailItem> = charts
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| ThumbnailItem {
|
||||||
|
chart_id: c.id,
|
||||||
|
label: SharedString::from(c.label),
|
||||||
|
subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
|
||||||
|
preview: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.canvas.update(cx, |c, cx| {
|
||||||
|
c.set_mode(
|
||||||
|
CanvasMode::Thumbnails {
|
||||||
|
scope: ThumbnailScope::Contact(id),
|
||||||
|
items,
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
|
||||||
|
}
|
||||||
|
TreeSelection::Group(id) => {
|
||||||
|
let charts = self.store.charts_under_group(id).unwrap_or_default();
|
||||||
|
let items: Vec<ThumbnailItem> = charts
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| ThumbnailItem {
|
||||||
|
chart_id: c.id,
|
||||||
|
label: SharedString::from(c.label),
|
||||||
|
subtitle: Some(SharedString::from(format!("{:?}", c.kind))),
|
||||||
|
preview: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.canvas.update(cx, |c, cx| {
|
||||||
|
c.set_mode(
|
||||||
|
CanvasMode::Thumbnails {
|
||||||
|
scope: ThumbnailScope::Group(id),
|
||||||
|
items,
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
self.panel.update(cx, |p, cx| p.set_active_kind(None, cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_panel_event(&mut self, ev: &PanelEvent, cx: &mut Context<Self>) {
|
||||||
|
match ev {
|
||||||
|
PanelEvent::ModuleToggled { module_id, .. } => {
|
||||||
|
self.canvas
|
||||||
|
.update(cx, |c, cx| c.toggle_module(module_id, cx));
|
||||||
|
}
|
||||||
|
PanelEvent::ControlChanged { .. } => {
|
||||||
|
// Fase 4: aplicar config al canvas + persistir en store.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Silenciar warnings de campos no leídos hasta que la fase 2
|
||||||
|
// cablee CRUD desde el tree.
|
||||||
|
let _ = (&self.store, &self.tree, &self.bus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Shell {
|
||||||
|
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let theme = Theme::global(cx).clone();
|
||||||
|
|
||||||
|
let header = div()
|
||||||
|
.h(px(34.0))
|
||||||
|
.px(px(12.0))
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.items_center()
|
||||||
|
.gap(px(10.0))
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(theme.border)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(13.0))
|
||||||
|
.text_color(theme.fg_text)
|
||||||
|
.child("☉ Tahuantinsuyu"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(10.0))
|
||||||
|
.text_color(theme.fg_muted)
|
||||||
|
.child("estudio de astrología profesional"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tree_panel = div()
|
||||||
|
.w(px(TREE_WIDTH))
|
||||||
|
.min_w(px(TREE_WIDTH))
|
||||||
|
.h_full()
|
||||||
|
.border_r_1()
|
||||||
|
.border_color(theme.border)
|
||||||
|
.child(self.tree.clone());
|
||||||
|
|
||||||
|
let canvas_panel = div()
|
||||||
|
.flex_grow()
|
||||||
|
.h_full()
|
||||||
|
.child(self.canvas.clone());
|
||||||
|
|
||||||
|
let main_row = div()
|
||||||
|
.flex_grow()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.child(tree_panel)
|
||||||
|
.child(canvas_panel);
|
||||||
|
|
||||||
|
let bottom_panel = div()
|
||||||
|
.h(px(PANEL_HEIGHT))
|
||||||
|
.min_h(px(PANEL_HEIGHT))
|
||||||
|
.w_full()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(theme.border)
|
||||||
|
.child(self.panel.clone());
|
||||||
|
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.bg(theme.bg_app.clone())
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.child(header)
|
||||||
|
.child(main_row)
|
||||||
|
.child(bottom_panel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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