feat(tahuantinsuyu): scaffolding del estudio astrológico (10 crates + ventana 3-panes)
Módulo nuevo `modules/tahuantinsuyu/` con 9 crates reusables + app `apps/tahuantinsuyu` ejecutable que abre la ventana del explorador y coordina los widgets: - tahuantinsuyu-card: Card Brahman + spawn_sidecar (flows chart-request/chart-result). - tahuantinsuyu-model: tipos agnósticos (Group/Contact/Chart, StoredBirthData, StoredChartConfig, ChartKind, TreeSelection). - tahuantinsuyu-store: persistencia SQLite (rusqlite) con migración v1, CRUD por entidad y descenso recursivo `charts_under_group`. - tahuantinsuyu-engine: bridge agnóstico al canvas vía `RenderModel` (Layer/Glyph/Geometry). Feature `eternal-bridge` (off por default) reservada para enchufar eternal-astrology desde ~/eternal. - tahuantinsuyu-modules: registry de módulos pluggables (Module trait + Control schema) con `NatalModule` placeholder. - tahuantinsuyu-theme: AstroPalette (elementos / modos / planetas / aspectos) con variantes dark + light sobre yahweh-theme. - tahuantinsuyu-canvas: widget GPUI con CanvasState (Empty / Wheel / Thumbnails). Render placeholder hasta cablear la rueda real. - tahuantinsuyu-tree: explorador izquierdo sobre yahweh-widget-tree, prefijos g:/c:/h: para Group/Contact/Chart. - tahuantinsuyu-panel: control panel inferior que lee Controls de los módulos del registry y los pinta. - apps/tahuantinsuyu: binario `tahuantinsuyu` (launch_app-style) con Shell coordinador (tree↔canvas↔panel), DB en $XDG_DATA_HOME. Workspace Cargo.toml actualizado con los 10 miembros. `cargo check` verde, tests unitarios verdes (model/store/engine/modules/theme/card). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "tahuantinsuyu-canvas"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Tahuantinsuyu — widget GPUI del canvas astrológico. Capas modulares, jog-dial perimetral, estado unificado."
|
||||
|
||||
[dependencies]
|
||||
tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" }
|
||||
tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
|
||||
tahuantinsuyu-modules = { path = "../tahuantinsuyu-modules" }
|
||||
tahuantinsuyu-theme = { path = "../tahuantinsuyu-theme" }
|
||||
yahweh-theme = { workspace = true }
|
||||
gpui = { workspace = true }
|
||||
@@ -0,0 +1,264 @@
|
||||
//! `tahuantinsuyu-canvas` — el widget GPUI del lienzo astrológico.
|
||||
//!
|
||||
//! Modela el cielo como un lienzo de **geometría reactiva**: un estado
|
||||
//! unificado [`CanvasState`] guarda offsets de rotación, flags de
|
||||
//! visibilidad y la lista de `Layer`s a pintar. Las interacciones
|
||||
//! (drag, hotkeys, toggles) mutan el estado; el render lee la última
|
||||
//! `RenderModel` y la deriva al frame.
|
||||
//!
|
||||
//! ## Modos
|
||||
//!
|
||||
//! - [`CanvasMode::Wheel`] — pinta una carta única (la rueda).
|
||||
//! - [`CanvasMode::Thumbnails`] — pinta una grilla de mini-cartas
|
||||
//! cuando el item activo del tree es un Group o Contact.
|
||||
//! - [`CanvasMode::Empty`] — sin selección.
|
||||
//!
|
||||
//! ## Fase 1
|
||||
//!
|
||||
//! Este crate trae el esqueleto: tipos, estado, render placeholder
|
||||
//! (caja cuadrada con título centrado, eje cardinal y un anillo
|
||||
//! perfilado). Las interacciones del jog-dial, el árbol Uraniano y la
|
||||
//! pintura de cada `Layer` vienen en fases siguientes.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use gpui::{
|
||||
Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use tahuantinsuyu_engine::RenderModel;
|
||||
use tahuantinsuyu_model::{ChartId, ContactId, GroupId};
|
||||
use tahuantinsuyu_theme::AstroPalette;
|
||||
use yahweh_theme::Theme;
|
||||
|
||||
// =====================================================================
|
||||
// Eventos
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CanvasEvent {
|
||||
/// El usuario hizo doble click sobre un thumbnail o pidió abrir la
|
||||
/// carta activa. El host (la app) decide si emitir al AppBus.
|
||||
ChartRequested(ChartId),
|
||||
/// El usuario rotó la rueda de tiempo: minutos de offset acumulados.
|
||||
TimeOffsetChanged(i64),
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Estado
|
||||
// =====================================================================
|
||||
|
||||
/// Modo de visualización del canvas.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum CanvasMode {
|
||||
#[default]
|
||||
Empty,
|
||||
/// Single chart wheel.
|
||||
Wheel { render: Box<RenderModel> },
|
||||
/// Grilla de thumbnails para un Group o Contact con varias cartas.
|
||||
Thumbnails {
|
||||
scope: ThumbnailScope,
|
||||
items: Vec<ThumbnailItem>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ThumbnailScope {
|
||||
Group(GroupId),
|
||||
Contact(ContactId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ThumbnailItem {
|
||||
pub chart_id: ChartId,
|
||||
pub label: SharedString,
|
||||
pub subtitle: Option<SharedString>,
|
||||
/// `Some` si ya hay un render-mock disponible. `None` = lazy.
|
||||
pub preview: Option<RenderModel>,
|
||||
}
|
||||
|
||||
/// Estado unificado del canvas. Inspirado en la conversación de Sergio
|
||||
/// con el agente — todo lo que controla qué se pinta vive acá.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CanvasState {
|
||||
pub mode: CanvasMode,
|
||||
|
||||
/// Rotación manual del lienzo en grados. `0.0` = Aries al este.
|
||||
pub view_rotation_deg: f32,
|
||||
|
||||
/// Offset acumulado del time-scrubbing (jog-dial perimetral) en
|
||||
/// minutos. La engine recalcula la `RenderModel` cuando esto cambia.
|
||||
pub time_offset_minutes: i64,
|
||||
|
||||
/// Capas activas por `module_id`. Si una capa del `RenderModel`
|
||||
/// pertenece a un módulo no presente aquí, no se pinta.
|
||||
pub active_modules: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Widget
|
||||
// =====================================================================
|
||||
|
||||
pub struct AstrologyCanvas {
|
||||
state: CanvasState,
|
||||
}
|
||||
|
||||
impl EventEmitter<CanvasEvent> for AstrologyCanvas {}
|
||||
|
||||
impl AstrologyCanvas {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||
Self {
|
||||
state: CanvasState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &CanvasState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Reemplaza el modo de visualización (lo que se pinta).
|
||||
pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context<Self>) {
|
||||
self.state.mode = mode;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn toggle_module(&mut self, module_id: &str, cx: &mut Context<Self>) {
|
||||
if !self.state.active_modules.remove(module_id) {
|
||||
self.state.active_modules.insert(module_id.to_string());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context<Self>) {
|
||||
self.state.view_rotation_deg = deg.rem_euclid(360.0);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Render
|
||||
// =====================================================================
|
||||
|
||||
impl Render for AstrologyCanvas {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
let palette = AstroPalette::for_theme(&theme);
|
||||
|
||||
let body = match &self.state.mode {
|
||||
CanvasMode::Empty => render_empty(&theme),
|
||||
CanvasMode::Wheel { render } => render_wheel(&theme, &palette, render),
|
||||
CanvasMode::Thumbnails { scope: _, items } => {
|
||||
render_thumbnails(&theme, &palette, items)
|
||||
}
|
||||
};
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.bg(theme.bg_panel.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(body)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_empty(theme: &Theme) -> gpui::Div {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap(px(12.0))
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(13.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child("Tahuantinsuyu"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_disabled)
|
||||
.child("Seleccioná una carta en el árbol para empezar."),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_wheel(theme: &Theme, palette: &AstroPalette, render: &RenderModel) -> gpui::Div {
|
||||
// Fase 1: placeholder visual. Una caja cuadrada con el título y un
|
||||
// contador de capas. El pintado real de los Layer vendrá con
|
||||
// `gpui::canvas` + matrices en la fase 3.
|
||||
let _ = palette; // silencia warning hasta la fase 3.
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap(px(10.0))
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(16.0))
|
||||
.text_color(theme.fg_text)
|
||||
.child(SharedString::from(render.title.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child(SharedString::from(format!(
|
||||
"{} capa(s) · {} ms",
|
||||
render.layers.len(),
|
||||
render.compute_ms
|
||||
))),
|
||||
)
|
||||
.child(
|
||||
// Marco cuadrado provisional — el render real lo ocupará.
|
||||
div()
|
||||
.size(px(480.0))
|
||||
.rounded(px(8.0))
|
||||
.border_1()
|
||||
.border_color(theme.border_strong)
|
||||
.bg(theme.bg_panel_alt.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_thumbnails(
|
||||
theme: &Theme,
|
||||
_palette: &AstroPalette,
|
||||
items: &[ThumbnailItem],
|
||||
) -> gpui::Div {
|
||||
if items.is_empty() {
|
||||
return div()
|
||||
.text_size(px(12.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child("Sin cartas en este grupo todavía.");
|
||||
}
|
||||
// Grid simple en flex-wrap. La fase 3 lo reemplaza por miniaturas
|
||||
// pintadas con la rueda en miniatura.
|
||||
let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0));
|
||||
for it in items {
|
||||
row = row.child(
|
||||
div()
|
||||
.w(px(140.0))
|
||||
.h(px(160.0))
|
||||
.rounded(px(6.0))
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.bg(theme.bg_panel_alt.clone())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.pb(px(8.0))
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_text)
|
||||
.child(it.label.clone()),
|
||||
),
|
||||
);
|
||||
}
|
||||
row
|
||||
}
|
||||
Reference in New Issue
Block a user