feat(tahuantinsuyu): fase 22 — layout splitter + atlas loadable desde XDG

Dos features de producción que mejoran la usabilidad sustancialmente.

## #7 — Layout reorganizable con SplitContainer

Los 3 paneles ya no tienen tamaños hardcodeados. Reusamos
yahweh-widget-splitter (mismo que usa yahweh-shell para sus layouts
JSON-config) con 2 niveles:

- outer (Vertical): main_split arriba (flex 4) + panel abajo (flex 1)
- main_split (Horizontal): tree (flex 1) + canvas (flex 4)

El usuario puede arrastrar los dos divisores para redimensionar
libremente. Por ejemplo: en una pantalla ancha, dar más al canvas; en
una sesión de lectura analítica, agrandar el panel abajo para ver más
módulos expandidos.

- Shell gana fields main_split + outer_split: Entity<SplitContainer>.
- new() construye ambos con ChildSlots envolviendo tree/canvas/panel
  como AnyView (mismo patrón que LayoutHost de yahweh-shell).
- render() simplificado: header + body(outer_split). Las constants
  TREE_WIDTH y PANEL_HEIGHT desaparecen.
- Cargo añade deps: yahweh-core (NodeId, LayoutDirection),
  yahweh-widget-splitter, yahweh-widget-container-core (ChildSlot).

## #15 — Atlas de ciudades cargable desde TSV

El array `CITY_PRESETS` const de 90 ciudades hardcoded ahora es la
función `default_city_presets() -> Vec<CityPreset>`. CityPreset.name
pasa de `&'static str` a `String` para que el atlas sea construible
en runtime.

TahuantinsuyuTree gana `city_atlas: Vec<CityPreset>` + setter
`set_city_atlas(atlas, cx)`. Al boot, Shell intenta cargar
`$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv` y, si existe + parsea bien,
reemplaza el atlas hardcoded.

Formato TSV (líneas):
  name<TAB>lat<TAB>lon<TAB>tz_offset_minutes
  Líneas vacías y `#` comentario se ignoran.
  Líneas con cualquier parse fallido se descartan en silencio.

API pública: `parse_city_atlas_tsv(&str) -> Vec<CityPreset>` (en
tahuantinsuyu-tree), reusable por tests/scripts.

El usuario que quiera 50.000 ciudades de GeoNames cities5000.txt:
1. wget cities5000.zip de geonames.org
2. awk para extraer (name, lat, lon, tz_offset) y escribir TSV
3. mover a $XDG_DATA_HOME/tahuantinsuyu/atlas.tsv
4. relanzar la app

Sin fricción adicional para el usuario común (los 90 hardcoded cubren
99% de casos típicos en español/inglés).

cargo check verde, 8 tests engine + 1 test modules verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 23:46:34 +00:00
parent d890bd4b3a
commit 295c9ba554
4 changed files with 281 additions and 146 deletions
+3
View File
@@ -17,8 +17,11 @@ tahuantinsuyu-theme = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-theme"
tahuantinsuyu-tree = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-tree" }
yahweh-bus = { workspace = true }
yahweh-core = { workspace = true }
yahweh-theme = { workspace = true }
yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" }
yahweh-widget-splitter = { workspace = true }
yahweh-widget-container-core = { workspace = true }
gpui = { workspace = true }
directories = { workspace = true }
serde_json = { workspace = true }
+93 -26
View File
@@ -38,9 +38,12 @@ use tahuantinsuyu_engine::{
use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
use tahuantinsuyu_store::Store;
use tahuantinsuyu_tree::{TahuantinsuyuTree, TreeEvent};
use tahuantinsuyu_tree::{parse_city_atlas_tsv, TahuantinsuyuTree, TreeEvent};
use yahweh_bus::AppBus;
use yahweh_core::{LayoutDirection, NodeId};
use yahweh_theme::Theme;
use yahweh_widget_container_core::ChildSlot;
use yahweh_widget_splitter::SplitContainer;
use yahweh_widget_theme_switcher::theme_switcher;
const TREE_WIDTH: f32 = 280.0;
@@ -53,6 +56,13 @@ pub struct Shell {
tree: Entity<TahuantinsuyuTree>,
canvas: Entity<AstrologyCanvas>,
panel: Entity<ControlPanel>,
/// Splitter horizontal entre tree (izq) y canvas (der). El divisor
/// es draggable — el flex se persiste in-memory mientras la app
/// está abierta.
main_split: Entity<SplitContainer>,
/// Splitter vertical entre el main_row (arriba) y el panel de
/// control (abajo).
outer_split: Entity<SplitContainer>,
current_chart: Option<Chart>,
current_offset_minutes: i64,
/// Estado de los módulos overlay (transit, progression, …) por
@@ -71,7 +81,16 @@ impl Shell {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
let bus = cx.new(|_| AppBus);
let tree = cx.new(|cx| TahuantinsuyuTree::new(store.clone(), cx));
let tree = cx.new(|cx| {
let mut t = TahuantinsuyuTree::new(store.clone(), cx);
// Si hay un atlas custom en $XDG_DATA_HOME/tahuantinsuyu/
// atlas.tsv, lo cargamos y reemplazamos el atlas hardcoded
// de 90 ciudades. Formato TSV: name<TAB>lat<TAB>lon<TAB>tz_min.
if let Some(atlas) = load_city_atlas_from_xdg() {
t.set_city_atlas(atlas, cx);
}
t
});
let canvas = cx.new(AstrologyCanvas::new);
let panel = cx.new(ControlPanel::new);
@@ -90,12 +109,59 @@ impl Shell {
})
.detach();
// Splitter horizontal: tree + canvas (flex 1 : 4).
let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx));
main_split.update(cx, |sc, cx| {
sc.set_children(
vec![
ChildSlot {
id: NodeId::new("tts-tree"),
flex: 1.0,
label: None,
view: gpui::AnyView::from(tree.clone()),
},
ChildSlot {
id: NodeId::new("tts-canvas"),
flex: 4.0,
label: None,
view: gpui::AnyView::from(canvas.clone()),
},
],
cx,
);
});
// Splitter vertical: main_split arriba, panel abajo (flex 4 : 1).
let outer_split = cx.new(|cx| {
let mut sc = SplitContainer::new(LayoutDirection::Vertical, cx);
sc.set_children(
vec![
ChildSlot {
id: NodeId::new("tts-main"),
flex: 4.0,
label: None,
view: gpui::AnyView::from(main_split.clone()),
},
ChildSlot {
id: NodeId::new("tts-panel"),
flex: 1.0,
label: None,
view: gpui::AnyView::from(panel.clone()),
},
],
cx,
);
sc
});
let mut shell = Self {
store,
bus,
tree,
canvas,
panel,
main_split,
outer_split,
current_chart: None,
current_offset_minutes: 0,
module_configs: HashMap::new(),
@@ -706,6 +772,28 @@ impl Shell {
// truth. Shell y canvas leen del mismo slice.
/// Lee `$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv` si existe y lo parsea
/// como atlas de ciudades. Devuelve `None` cuando no hay archivo o
/// quedó vacío después del parse — el tree cae al atlas hardcoded.
fn load_city_atlas_from_xdg() -> Option<Vec<tahuantinsuyu_tree::CityPreset>> {
let path = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
.map(|d| d.data_dir().join("atlas.tsv"))?;
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let atlas = parse_city_atlas_tsv(&content);
if atlas.is_empty() {
eprintln!(
"[shell] atlas.tsv encontrado en {:?} pero sin filas válidas — fallback a hardcoded",
path
);
return None;
}
eprintln!("[shell] atlas custom cargado: {} ciudades", atlas.len());
Some(atlas)
}
/// Etiqueta breve para mostrar al elegir una carta en el picker:
/// `"YYYY-MM-DD · Lugar"` cuando hay lugar, sino solo la fecha.
fn format_birth_brief(birth: &tahuantinsuyu_model::StoredBirthData) -> String {
@@ -780,30 +868,10 @@ impl Render for Shell {
.child(div().flex_grow())
.child(theme_switcher(cx));
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()
let body = 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());
.child(self.outer_split.clone());
div()
.size_full()
@@ -811,7 +879,6 @@ impl Render for Shell {
.flex()
.flex_col()
.child(header)
.child(main_row)
.child(bottom_panel)
.child(body)
}
}