tawasuyu

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Definitions

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>

295c9ba554fc693550cdec72b33f837cef714c6b · by sergio · (5 months ago) · parent d890bd4b3 · browse the tree at this commit

Semantic delta · 13

crates/apps/tahuantinsuyu/src/shell.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs

4 files · +281 −146

diff --git a/Cargo.lock b/Cargo.lock
index fff98f7..3b83ae6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10917,7 +10917,10 @@ dependencies = [
  "tahuantinsuyu-theme",
  "tahuantinsuyu-tree",
  "yahweh-bus",
+ "yahweh-core",
  "yahweh-theme",
+ "yahweh-widget-container-core",
+ "yahweh-widget-splitter",
  "yahweh-widget-theme-switcher",
 ]
 
diff --git a/crates/apps/tahuantinsuyu/Cargo.toml b/crates/apps/tahuantinsuyu/Cargo.toml
index 0cd08cb..3732241 100644
--- a/crates/apps/tahuantinsuyu/Cargo.toml
+++ b/crates/apps/tahuantinsuyu/Cargo.toml
@@ -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 }
diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index b783f5c..c6cdea4 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -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)
     }
 }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
index c706732..79fa638 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
@@ -151,127 +151,173 @@ pub struct TahuantinsuyuTree {
     /// está abierto. Vive en el tree (no en ChartForm) porque las
     /// closures de los click handlers necesitan mutarlo via `cx.listener`.
     city_picker_open: bool,
+    /// Atlas de ciudades para el dropdown del form. Se inicializa con
+    /// `default_city_presets()` (90 ciudades hardcoded). El host puede
+    /// llamar [`Self::set_city_atlas`] para reemplazar por uno custom
+    /// cargado desde disco (TSV).
+    city_atlas: Vec<CityPreset>,
 }
 
 /// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
 /// al elegirlo en el form. TZ es la zona estándar **sin DST** — el
-/// usuario afina si necesita.
-#[derive(Clone, Copy)]
-struct CityPreset {
-    name: &'static str,
-    lat: f64,
-    lon: f64,
-    tz_offset_minutes: i32,
+/// usuario afina si necesita. `name` es `String` (no &'static) para
+/// permitir cargar atlas custom desde disco vía
+/// [`TahuantinsuyuTree::set_city_atlas`].
+#[derive(Clone, Debug)]
+pub struct CityPreset {
+    pub name: String,
+    pub lat: f64,
+    pub lon: f64,
+    pub tz_offset_minutes: i32,
 }
 
-const CITY_PRESETS: &[CityPreset] = &[
-    // Latinoamérica
-    CityPreset { name: "Buenos Aires, AR", lat: -34.6037, lon: -58.3816, tz_offset_minutes: -180 },
-    CityPreset { name: "Córdoba, AR",      lat: -31.4201, lon: -64.1888, tz_offset_minutes: -180 },
-    CityPreset { name: "Rosario, AR",      lat: -32.9587, lon: -60.6930, tz_offset_minutes: -180 },
-    CityPreset { name: "Mendoza, AR",      lat: -32.8908, lon: -68.8272, tz_offset_minutes: -180 },
-    CityPreset { name: "Caracas, VE",      lat: 10.4806,  lon: -66.9036, tz_offset_minutes: -240 },
-    CityPreset { name: "Maracaibo, VE",    lat: 10.6427,  lon: -71.6125, tz_offset_minutes: -240 },
-    CityPreset { name: "Valencia, VE",     lat: 10.1620,  lon: -68.0078, tz_offset_minutes: -240 },
-    CityPreset { name: "Bogotá, CO",       lat: 4.7110,   lon: -74.0721, tz_offset_minutes: -300 },
-    CityPreset { name: "Medellín, CO",     lat: 6.2442,   lon: -75.5812, tz_offset_minutes: -300 },
-    CityPreset { name: "Cali, CO",         lat: 3.4516,   lon: -76.5320, tz_offset_minutes: -300 },
-    CityPreset { name: "Lima, PE",         lat: -12.0464, lon: -77.0428, tz_offset_minutes: -300 },
-    CityPreset { name: "Cusco, PE",        lat: -13.5319, lon: -71.9675, tz_offset_minutes: -300 },
-    CityPreset { name: "Santiago, CL",     lat: -33.4489, lon: -70.6693, tz_offset_minutes: -240 },
-    CityPreset { name: "Valparaíso, CL",   lat: -33.0472, lon: -71.6127, tz_offset_minutes: -240 },
-    CityPreset { name: "Quito, EC",        lat: -0.1807,  lon: -78.4678, tz_offset_minutes: -300 },
-    CityPreset { name: "Guayaquil, EC",    lat: -2.1709,  lon: -79.9224, tz_offset_minutes: -300 },
-    CityPreset { name: "Montevideo, UY",   lat: -34.9011, lon: -56.1645, tz_offset_minutes: -180 },
-    CityPreset { name: "Asunción, PY",     lat: -25.2637, lon: -57.5759, tz_offset_minutes: -240 },
-    CityPreset { name: "La Paz, BO",       lat: -16.4897, lon: -68.1193, tz_offset_minutes: -240 },
-    CityPreset { name: "Ciudad de México", lat: 19.4326,  lon: -99.1332, tz_offset_minutes: -360 },
-    CityPreset { name: "Guadalajara, MX",  lat: 20.6597,  lon: -103.3496, tz_offset_minutes: -360 },
-    CityPreset { name: "Monterrey, MX",    lat: 25.6866,  lon: -100.3161, tz_offset_minutes: -360 },
-    CityPreset { name: "Habana, CU",       lat: 23.1136,  lon: -82.3666, tz_offset_minutes: -300 },
-    CityPreset { name: "San Juan, PR",     lat: 18.4655,  lon: -66.1057, tz_offset_minutes: -240 },
-    CityPreset { name: "San José, CR",     lat: 9.9281,   lon: -84.0907, tz_offset_minutes: -360 },
-    CityPreset { name: "Panamá, PA",       lat: 8.9824,   lon: -79.5199, tz_offset_minutes: -300 },
-    CityPreset { name: "San Salvador, SV", lat: 13.6929,  lon: -89.2182, tz_offset_minutes: -360 },
-    CityPreset { name: "Guatemala, GT",    lat: 14.6349,  lon: -90.5069, tz_offset_minutes: -360 },
-    CityPreset { name: "Tegucigalpa, HN",  lat: 14.0723,  lon: -87.1921, tz_offset_minutes: -360 },
-    CityPreset { name: "Managua, NI",      lat: 12.1149,  lon: -86.2362, tz_offset_minutes: -360 },
-    CityPreset { name: "Santo Domingo, DO", lat: 18.4861, lon: -69.9312, tz_offset_minutes: -240 },
-    CityPreset { name: "São Paulo, BR",    lat: -23.5505, lon: -46.6333, tz_offset_minutes: -180 },
-    CityPreset { name: "Rio de Janeiro, BR", lat: -22.9068, lon: -43.1729, tz_offset_minutes: -180 },
-    CityPreset { name: "Brasília, BR",     lat: -15.8267, lon: -47.9218, tz_offset_minutes: -180 },
-    CityPreset { name: "Salvador, BR",     lat: -12.9777, lon: -38.5016, tz_offset_minutes: -180 },
+/// Atlas hardcoded — 90 ciudades canónicas que cubren la mayoría de
+/// casos de uso. El usuario puede sobrescribirlas pasando un atlas
+/// custom vía [`TahuantinsuyuTree::set_city_atlas`] (típicamente
+/// cargado desde `$XDG_DATA_HOME/tahuantinsuyu/atlas.tsv`).
+pub fn default_city_presets() -> Vec<CityPreset> {
+    vec![
+        // Latinoamérica
+        CityPreset { name: "Buenos Aires, AR".into(), lat: -34.6037, lon: -58.3816, tz_offset_minutes: -180 },
+    CityPreset { name: "Córdoba, AR".into(),      lat: -31.4201, lon: -64.1888, tz_offset_minutes: -180 },
+    CityPreset { name: "Rosario, AR".into(),      lat: -32.9587, lon: -60.6930, tz_offset_minutes: -180 },
+    CityPreset { name: "Mendoza, AR".into(),      lat: -32.8908, lon: -68.8272, tz_offset_minutes: -180 },
+    CityPreset { name: "Caracas, VE".into(),      lat: 10.4806,  lon: -66.9036, tz_offset_minutes: -240 },
+    CityPreset { name: "Maracaibo, VE".into(),    lat: 10.6427,  lon: -71.6125, tz_offset_minutes: -240 },
+    CityPreset { name: "Valencia, VE".into(),     lat: 10.1620,  lon: -68.0078, tz_offset_minutes: -240 },
+    CityPreset { name: "Bogotá, CO".into(),       lat: 4.7110,   lon: -74.0721, tz_offset_minutes: -300 },
+    CityPreset { name: "Medellín, CO".into(),     lat: 6.2442,   lon: -75.5812, tz_offset_minutes: -300 },
+    CityPreset { name: "Cali, CO".into(),         lat: 3.4516,   lon: -76.5320, tz_offset_minutes: -300 },
+    CityPreset { name: "Lima, PE".into(),         lat: -12.0464, lon: -77.0428, tz_offset_minutes: -300 },
+    CityPreset { name: "Cusco, PE".into(),        lat: -13.5319, lon: -71.9675, tz_offset_minutes: -300 },
+    CityPreset { name: "Santiago, CL".into(),     lat: -33.4489, lon: -70.6693, tz_offset_minutes: -240 },
+    CityPreset { name: "Valparaíso, CL".into(),   lat: -33.0472, lon: -71.6127, tz_offset_minutes: -240 },
+    CityPreset { name: "Quito, EC".into(),        lat: -0.1807,  lon: -78.4678, tz_offset_minutes: -300 },
+    CityPreset { name: "Guayaquil, EC".into(),    lat: -2.1709,  lon: -79.9224, tz_offset_minutes: -300 },
+    CityPreset { name: "Montevideo, UY".into(),   lat: -34.9011, lon: -56.1645, tz_offset_minutes: -180 },
+    CityPreset { name: "Asunción, PY".into(),     lat: -25.2637, lon: -57.5759, tz_offset_minutes: -240 },
+    CityPreset { name: "La Paz, BO".into(),       lat: -16.4897, lon: -68.1193, tz_offset_minutes: -240 },
+    CityPreset { name: "Ciudad de México".into(), lat: 19.4326,  lon: -99.1332, tz_offset_minutes: -360 },
+    CityPreset { name: "Guadalajara, MX".into(),  lat: 20.6597,  lon: -103.3496, tz_offset_minutes: -360 },
+    CityPreset { name: "Monterrey, MX".into(),    lat: 25.6866,  lon: -100.3161, tz_offset_minutes: -360 },
+    CityPreset { name: "Habana, CU".into(),       lat: 23.1136,  lon: -82.3666, tz_offset_minutes: -300 },
+    CityPreset { name: "San Juan, PR".into(),     lat: 18.4655,  lon: -66.1057, tz_offset_minutes: -240 },
+    CityPreset { name: "San José, CR".into(),     lat: 9.9281,   lon: -84.0907, tz_offset_minutes: -360 },
+    CityPreset { name: "Panamá, PA".into(),       lat: 8.9824,   lon: -79.5199, tz_offset_minutes: -300 },
+    CityPreset { name: "San Salvador, SV".into(), lat: 13.6929,  lon: -89.2182, tz_offset_minutes: -360 },
+    CityPreset { name: "Guatemala, GT".into(),    lat: 14.6349,  lon: -90.5069, tz_offset_minutes: -360 },
+    CityPreset { name: "Tegucigalpa, HN".into(),  lat: 14.0723,  lon: -87.1921, tz_offset_minutes: -360 },
+    CityPreset { name: "Managua, NI".into(),      lat: 12.1149,  lon: -86.2362, tz_offset_minutes: -360 },
+    CityPreset { name: "Santo Domingo, DO".into(), lat: 18.4861, lon: -69.9312, tz_offset_minutes: -240 },
+    CityPreset { name: "São Paulo, BR".into(),    lat: -23.5505, lon: -46.6333, tz_offset_minutes: -180 },
+    CityPreset { name: "Rio de Janeiro, BR".into(), lat: -22.9068, lon: -43.1729, tz_offset_minutes: -180 },
+    CityPreset { name: "Brasília, BR".into(),     lat: -15.8267, lon: -47.9218, tz_offset_minutes: -180 },
+    CityPreset { name: "Salvador, BR".into(),     lat: -12.9777, lon: -38.5016, tz_offset_minutes: -180 },
     // España
-    CityPreset { name: "Madrid, ES",       lat: 40.4168,  lon: -3.7038,  tz_offset_minutes: 60 },
-    CityPreset { name: "Barcelona, ES",    lat: 41.3851,  lon: 2.1734,   tz_offset_minutes: 60 },
-    CityPreset { name: "Sevilla, ES",      lat: 37.3891,  lon: -5.9845,  tz_offset_minutes: 60 },
-    CityPreset { name: "Valencia, ES",     lat: 39.4699,  lon: -0.3763,  tz_offset_minutes: 60 },
-    CityPreset { name: "Bilbao, ES",       lat: 43.2630,  lon: -2.9350,  tz_offset_minutes: 60 },
+    CityPreset { name: "Madrid, ES".into(),       lat: 40.4168,  lon: -3.7038,  tz_offset_minutes: 60 },
+    CityPreset { name: "Barcelona, ES".into(),    lat: 41.3851,  lon: 2.1734,   tz_offset_minutes: 60 },
+    CityPreset { name: "Sevilla, ES".into(),      lat: 37.3891,  lon: -5.9845,  tz_offset_minutes: 60 },
+    CityPreset { name: "Valencia, ES".into(),     lat: 39.4699,  lon: -0.3763,  tz_offset_minutes: 60 },
+    CityPreset { name: "Bilbao, ES".into(),       lat: 43.2630,  lon: -2.9350,  tz_offset_minutes: 60 },
     // Europa
-    CityPreset { name: "London, UK",       lat: 51.5074,  lon: -0.1278,  tz_offset_minutes: 0 },
-    CityPreset { name: "Paris, FR",        lat: 48.8566,  lon: 2.3522,   tz_offset_minutes: 60 },
-    CityPreset { name: "Berlin, DE",       lat: 52.5200,  lon: 13.4050,  tz_offset_minutes: 60 },
-    CityPreset { name: "München, DE",      lat: 48.1351,  lon: 11.5820,  tz_offset_minutes: 60 },
-    CityPreset { name: "Roma, IT",         lat: 41.9028,  lon: 12.4964,  tz_offset_minutes: 60 },
-    CityPreset { name: "Milano, IT",       lat: 45.4642,  lon: 9.1900,   tz_offset_minutes: 60 },
-    CityPreset { name: "Amsterdam, NL",    lat: 52.3676,  lon: 4.9041,   tz_offset_minutes: 60 },
-    CityPreset { name: "Bruxelles, BE",    lat: 50.8503,  lon: 4.3517,   tz_offset_minutes: 60 },
-    CityPreset { name: "Wien, AT",         lat: 48.2082,  lon: 16.3738,  tz_offset_minutes: 60 },
-    CityPreset { name: "Zürich, CH",       lat: 47.3769,  lon: 8.5417,   tz_offset_minutes: 60 },
-    CityPreset { name: "Lisboa, PT",       lat: 38.7223,  lon: -9.1393,  tz_offset_minutes: 0 },
-    CityPreset { name: "Dublin, IE",       lat: 53.3498,  lon: -6.2603,  tz_offset_minutes: 0 },
-    CityPreset { name: "Stockholm, SE",    lat: 59.3293,  lon: 18.0686,  tz_offset_minutes: 60 },
-    CityPreset { name: "Oslo, NO",         lat: 59.9139,  lon: 10.7522,  tz_offset_minutes: 60 },
-    CityPreset { name: "København, DK",    lat: 55.6761,  lon: 12.5683,  tz_offset_minutes: 60 },
-    CityPreset { name: "Helsinki, FI",     lat: 60.1699,  lon: 24.9384,  tz_offset_minutes: 120 },
-    CityPreset { name: "Warszawa, PL",     lat: 52.2297,  lon: 21.0122,  tz_offset_minutes: 60 },
-    CityPreset { name: "Praha, CZ",        lat: 50.0755,  lon: 14.4378,  tz_offset_minutes: 60 },
-    CityPreset { name: "Budapest, HU",     lat: 47.4979,  lon: 19.0402,  tz_offset_minutes: 60 },
-    CityPreset { name: "Athina, GR",       lat: 37.9838,  lon: 23.7275,  tz_offset_minutes: 120 },
-    CityPreset { name: "İstanbul, TR",     lat: 41.0082,  lon: 28.9784,  tz_offset_minutes: 180 },
-    CityPreset { name: "Moskva, RU",       lat: 55.7558,  lon: 37.6173,  tz_offset_minutes: 180 },
+    CityPreset { name: "London, UK".into(),       lat: 51.5074,  lon: -0.1278,  tz_offset_minutes: 0 },
+    CityPreset { name: "Paris, FR".into(),        lat: 48.8566,  lon: 2.3522,   tz_offset_minutes: 60 },
+    CityPreset { name: "Berlin, DE".into(),       lat: 52.5200,  lon: 13.4050,  tz_offset_minutes: 60 },
+    CityPreset { name: "München, DE".into(),      lat: 48.1351,  lon: 11.5820,  tz_offset_minutes: 60 },
+    CityPreset { name: "Roma, IT".into(),         lat: 41.9028,  lon: 12.4964,  tz_offset_minutes: 60 },
+    CityPreset { name: "Milano, IT".into(),       lat: 45.4642,  lon: 9.1900,   tz_offset_minutes: 60 },
+    CityPreset { name: "Amsterdam, NL".into(),    lat: 52.3676,  lon: 4.9041,   tz_offset_minutes: 60 },
+    CityPreset { name: "Bruxelles, BE".into(),    lat: 50.8503,  lon: 4.3517,   tz_offset_minutes: 60 },
+    CityPreset { name: "Wien, AT".into(),         lat: 48.2082,  lon: 16.3738,  tz_offset_minutes: 60 },
+    CityPreset { name: "Zürich, CH".into(),       lat: 47.3769,  lon: 8.5417,   tz_offset_minutes: 60 },
+    CityPreset { name: "Lisboa, PT".into(),       lat: 38.7223,  lon: -9.1393,  tz_offset_minutes: 0 },
+    CityPreset { name: "Dublin, IE".into(),       lat: 53.3498,  lon: -6.2603,  tz_offset_minutes: 0 },
+    CityPreset { name: "Stockholm, SE".into(),    lat: 59.3293,  lon: 18.0686,  tz_offset_minutes: 60 },
+    CityPreset { name: "Oslo, NO".into(),         lat: 59.9139,  lon: 10.7522,  tz_offset_minutes: 60 },
+    CityPreset { name: "København, DK".into(),    lat: 55.6761,  lon: 12.5683,  tz_offset_minutes: 60 },
+    CityPreset { name: "Helsinki, FI".into(),     lat: 60.1699,  lon: 24.9384,  tz_offset_minutes: 120 },
+    CityPreset { name: "Warszawa, PL".into(),     lat: 52.2297,  lon: 21.0122,  tz_offset_minutes: 60 },
+    CityPreset { name: "Praha, CZ".into(),        lat: 50.0755,  lon: 14.4378,  tz_offset_minutes: 60 },
+    CityPreset { name: "Budapest, HU".into(),     lat: 47.4979,  lon: 19.0402,  tz_offset_minutes: 60 },
+    CityPreset { name: "Athina, GR".into(),       lat: 37.9838,  lon: 23.7275,  tz_offset_minutes: 120 },
+    CityPreset { name: "İstanbul, TR".into(),     lat: 41.0082,  lon: 28.9784,  tz_offset_minutes: 180 },
+    CityPreset { name: "Moskva, RU".into(),       lat: 55.7558,  lon: 37.6173,  tz_offset_minutes: 180 },
     // USA + Canada
-    CityPreset { name: "New York, US",     lat: 40.7128,  lon: -74.0060, tz_offset_minutes: -300 },
-    CityPreset { name: "Los Angeles, US",  lat: 34.0522,  lon: -118.2437, tz_offset_minutes: -480 },
-    CityPreset { name: "Chicago, US",      lat: 41.8781,  lon: -87.6298, tz_offset_minutes: -360 },
-    CityPreset { name: "Miami, US",        lat: 25.7617,  lon: -80.1918, tz_offset_minutes: -300 },
-    CityPreset { name: "Houston, US",      lat: 29.7604,  lon: -95.3698, tz_offset_minutes: -360 },
-    CityPreset { name: "San Francisco, US", lat: 37.7749, lon: -122.4194, tz_offset_minutes: -480 },
-    CityPreset { name: "Seattle, US",      lat: 47.6062,  lon: -122.3321, tz_offset_minutes: -480 },
-    CityPreset { name: "Boston, US",       lat: 42.3601,  lon: -71.0589, tz_offset_minutes: -300 },
-    CityPreset { name: "Washington DC",    lat: 38.9072,  lon: -77.0369, tz_offset_minutes: -300 },
-    CityPreset { name: "Toronto, CA",      lat: 43.6532,  lon: -79.3832, tz_offset_minutes: -300 },
-    CityPreset { name: "Montreal, CA",     lat: 45.5017,  lon: -73.5673, tz_offset_minutes: -300 },
-    CityPreset { name: "Vancouver, CA",    lat: 49.2827,  lon: -123.1207, tz_offset_minutes: -480 },
+    CityPreset { name: "New York, US".into(),     lat: 40.7128,  lon: -74.0060, tz_offset_minutes: -300 },
+    CityPreset { name: "Los Angeles, US".into(),  lat: 34.0522,  lon: -118.2437, tz_offset_minutes: -480 },
+    CityPreset { name: "Chicago, US".into(),      lat: 41.8781,  lon: -87.6298, tz_offset_minutes: -360 },
+    CityPreset { name: "Miami, US".into(),        lat: 25.7617,  lon: -80.1918, tz_offset_minutes: -300 },
+    CityPreset { name: "Houston, US".into(),      lat: 29.7604,  lon: -95.3698, tz_offset_minutes: -360 },
+    CityPreset { name: "San Francisco, US".into(), lat: 37.7749, lon: -122.4194, tz_offset_minutes: -480 },
+    CityPreset { name: "Seattle, US".into(),      lat: 47.6062,  lon: -122.3321, tz_offset_minutes: -480 },
+    CityPreset { name: "Boston, US".into(),       lat: 42.3601,  lon: -71.0589, tz_offset_minutes: -300 },
+    CityPreset { name: "Washington DC".into(),    lat: 38.9072,  lon: -77.0369, tz_offset_minutes: -300 },
+    CityPreset { name: "Toronto, CA".into(),      lat: 43.6532,  lon: -79.3832, tz_offset_minutes: -300 },
+    CityPreset { name: "Montreal, CA".into(),     lat: 45.5017,  lon: -73.5673, tz_offset_minutes: -300 },
+    CityPreset { name: "Vancouver, CA".into(),    lat: 49.2827,  lon: -123.1207, tz_offset_minutes: -480 },
     // Asia
-    CityPreset { name: "Tokyo, JP",        lat: 35.6762,  lon: 139.6503, tz_offset_minutes: 540 },
-    CityPreset { name: "Beijing, CN",      lat: 39.9042,  lon: 116.4074, tz_offset_minutes: 480 },
-    CityPreset { name: "Shanghai, CN",     lat: 31.2304,  lon: 121.4737, tz_offset_minutes: 480 },
-    CityPreset { name: "Hong Kong",        lat: 22.3193,  lon: 114.1694, tz_offset_minutes: 480 },
-    CityPreset { name: "Singapore",        lat: 1.3521,   lon: 103.8198, tz_offset_minutes: 480 },
-    CityPreset { name: "Seoul, KR",        lat: 37.5665,  lon: 126.9780, tz_offset_minutes: 540 },
-    CityPreset { name: "Bangkok, TH",      lat: 13.7563,  lon: 100.5018, tz_offset_minutes: 420 },
-    CityPreset { name: "Jakarta, ID",      lat: -6.2088,  lon: 106.8456, tz_offset_minutes: 420 },
-    CityPreset { name: "Manila, PH",       lat: 14.5995,  lon: 120.9842, tz_offset_minutes: 480 },
-    CityPreset { name: "Mumbai, IN",       lat: 19.0760,  lon: 72.8777,  tz_offset_minutes: 330 },
-    CityPreset { name: "Delhi, IN",        lat: 28.7041,  lon: 77.1025,  tz_offset_minutes: 330 },
-    CityPreset { name: "Bangalore, IN",    lat: 12.9716,  lon: 77.5946,  tz_offset_minutes: 330 },
-    CityPreset { name: "Karachi, PK",      lat: 24.8607,  lon: 67.0011,  tz_offset_minutes: 300 },
-    CityPreset { name: "Tehran, IR",       lat: 35.6892,  lon: 51.3890,  tz_offset_minutes: 210 },
-    CityPreset { name: "Dubai, AE",        lat: 25.2048,  lon: 55.2708,  tz_offset_minutes: 240 },
-    CityPreset { name: "Tel Aviv, IL",     lat: 32.0853,  lon: 34.7818,  tz_offset_minutes: 120 },
+    CityPreset { name: "Tokyo, JP".into(),        lat: 35.6762,  lon: 139.6503, tz_offset_minutes: 540 },
+    CityPreset { name: "Beijing, CN".into(),      lat: 39.9042,  lon: 116.4074, tz_offset_minutes: 480 },
+    CityPreset { name: "Shanghai, CN".into(),     lat: 31.2304,  lon: 121.4737, tz_offset_minutes: 480 },
+    CityPreset { name: "Hong Kong".into(),        lat: 22.3193,  lon: 114.1694, tz_offset_minutes: 480 },
+    CityPreset { name: "Singapore".into(),        lat: 1.3521,   lon: 103.8198, tz_offset_minutes: 480 },
+    CityPreset { name: "Seoul, KR".into(),        lat: 37.5665,  lon: 126.9780, tz_offset_minutes: 540 },
+    CityPreset { name: "Bangkok, TH".into(),      lat: 13.7563,  lon: 100.5018, tz_offset_minutes: 420 },
+    CityPreset { name: "Jakarta, ID".into(),      lat: -6.2088,  lon: 106.8456, tz_offset_minutes: 420 },
+    CityPreset { name: "Manila, PH".into(),       lat: 14.5995,  lon: 120.9842, tz_offset_minutes: 480 },
+    CityPreset { name: "Mumbai, IN".into(),       lat: 19.0760,  lon: 72.8777,  tz_offset_minutes: 330 },
+    CityPreset { name: "Delhi, IN".into(),        lat: 28.7041,  lon: 77.1025,  tz_offset_minutes: 330 },
+    CityPreset { name: "Bangalore, IN".into(),    lat: 12.9716,  lon: 77.5946,  tz_offset_minutes: 330 },
+    CityPreset { name: "Karachi, PK".into(),      lat: 24.8607,  lon: 67.0011,  tz_offset_minutes: 300 },
+    CityPreset { name: "Tehran, IR".into(),       lat: 35.6892,  lon: 51.3890,  tz_offset_minutes: 210 },
+    CityPreset { name: "Dubai, AE".into(),        lat: 25.2048,  lon: 55.2708,  tz_offset_minutes: 240 },
+    CityPreset { name: "Tel Aviv, IL".into(),     lat: 32.0853,  lon: 34.7818,  tz_offset_minutes: 120 },
     // África
-    CityPreset { name: "Cairo, EG",        lat: 30.0444,  lon: 31.2357,  tz_offset_minutes: 120 },
-    CityPreset { name: "Lagos, NG",        lat: 6.5244,   lon: 3.3792,   tz_offset_minutes: 60 },
-    CityPreset { name: "Nairobi, KE",      lat: -1.2921,  lon: 36.8219,  tz_offset_minutes: 180 },
-    CityPreset { name: "Johannesburg, ZA", lat: -26.2041, lon: 28.0473,  tz_offset_minutes: 120 },
-    CityPreset { name: "Cape Town, ZA",    lat: -33.9249, lon: 18.4241,  tz_offset_minutes: 120 },
-    CityPreset { name: "Casablanca, MA",   lat: 33.5731,  lon: -7.5898,  tz_offset_minutes: 60 },
+    CityPreset { name: "Cairo, EG".into(),        lat: 30.0444,  lon: 31.2357,  tz_offset_minutes: 120 },
+    CityPreset { name: "Lagos, NG".into(),        lat: 6.5244,   lon: 3.3792,   tz_offset_minutes: 60 },
+    CityPreset { name: "Nairobi, KE".into(),      lat: -1.2921,  lon: 36.8219,  tz_offset_minutes: 180 },
+    CityPreset { name: "Johannesburg, ZA".into(), lat: -26.2041, lon: 28.0473,  tz_offset_minutes: 120 },
+    CityPreset { name: "Cape Town, ZA".into(),    lat: -33.9249, lon: 18.4241,  tz_offset_minutes: 120 },
+    CityPreset { name: "Casablanca, MA".into(),   lat: 33.5731,  lon: -7.5898,  tz_offset_minutes: 60 },
     // Oceanía
-    CityPreset { name: "Sydney, AU",       lat: -33.8688, lon: 151.2093, tz_offset_minutes: 600 },
-    CityPreset { name: "Melbourne, AU",    lat: -37.8136, lon: 144.9631, tz_offset_minutes: 600 },
-    CityPreset { name: "Auckland, NZ",     lat: -36.8485, lon: 174.7633, tz_offset_minutes: 720 },
-];
+    CityPreset { name: "Sydney, AU".into(),       lat: -33.8688, lon: 151.2093, tz_offset_minutes: 600 },
+    CityPreset { name: "Melbourne, AU".into(),    lat: -37.8136, lon: 144.9631, tz_offset_minutes: 600 },
+    CityPreset { name: "Auckland, NZ".into(),     lat: -36.8485, lon: 174.7633, tz_offset_minutes: 720 },
+    ]
+}
+
+/// Parsea un atlas TSV (tab-separated values): cada línea no vacía y
+/// no comentario es `name<TAB>lat<TAB>lon<TAB>tz_offset_minutes`.
+/// Devuelve solo las filas válidas — las inválidas se descartan en
+/// silencio (no abortamos la carga por una línea mal formada).
+pub fn parse_city_atlas_tsv(content: &str) -> Vec<CityPreset> {
+    let mut out = Vec::new();
+    for line in content.lines() {
+        let line = line.trim();
+        if line.is_empty() || line.starts_with('#') {
+            continue;
+        }
+        let parts: Vec<&str> = line.split('\t').collect();
+        if parts.len() < 4 {
+            continue;
+        }
+        let name = parts[0].trim().to_string();
+        let lat = parts[1].trim().parse::<f64>();
+        let lon = parts[2].trim().parse::<f64>();
+        let tz = parts[3].trim().parse::<i32>();
+        if let (Ok(lat), Ok(lon), Ok(tz)) = (lat, lon, tz) {
+            if !name.is_empty() {
+                out.push(CityPreset {
+                    name,
+                    lat,
+                    lon,
+                    tz_offset_minutes: tz,
+                });
+            }
+        }
+    }
+    out
+}
 
 impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
 
@@ -292,11 +338,21 @@ impl TahuantinsuyuTree {
             menu: None,
             modal: None,
             city_picker_open: false,
+            city_atlas: default_city_presets(),
         };
         me.refresh(cx);
         me
     }
 
+    /// Reemplaza el atlas de ciudades del dropdown. La app llama esto
+    /// al boot si encuentra un archivo TSV custom en disco.
+    pub fn set_city_atlas(&mut self, atlas: Vec<CityPreset>, cx: &mut Context<Self>) {
+        if !atlas.is_empty() {
+            self.city_atlas = atlas;
+            cx.notify();
+        }
+    }
+
     pub fn refresh(&mut self, cx: &mut Context<Self>) {
         let mut rows = Vec::new();
         self.append_groups(None, 0, &mut rows);
@@ -430,7 +486,7 @@ impl TahuantinsuyuTree {
     /// Aplica un city preset al ChartForm activo (CreateChart o
     /// EditChart). Setea place, lat, lon, tz_offset_min vía
     /// `TextInput::set_text` y cierra el picker.
-    fn apply_city_preset(&mut self, preset: CityPreset, cx: &mut Context<Self>) {
+    fn apply_city_preset(&mut self, preset: &CityPreset, cx: &mut Context<Self>) {
         let form = match self.modal.as_mut() {
             Some(Modal::CreateChart { form, .. }) => form,
             Some(Modal::EditChart { form, .. }) => form,
@@ -444,12 +500,14 @@ impl TahuantinsuyuTree {
         let lat = form.lat.clone();
         let lon = form.lon.clone();
         let tz = form.tz_offset_min.clone();
-        place.update(cx, |i, cx| i.set_text(preset.name.to_string(), cx));
-        lat.update(cx, |i, cx| i.set_text(format!("{}", preset.lat), cx));
-        lon.update(cx, |i, cx| i.set_text(format!("{}", preset.lon), cx));
-        tz.update(cx, |i, cx| {
-            i.set_text(preset.tz_offset_minutes.to_string(), cx)
-        });
+        let name = preset.name.clone();
+        let lat_val = preset.lat;
+        let lon_val = preset.lon;
+        let tz_val = preset.tz_offset_minutes;
+        place.update(cx, |i, cx| i.set_text(name, cx));
+        lat.update(cx, |i, cx| i.set_text(format!("{}", lat_val), cx));
+        lon.update(cx, |i, cx| i.set_text(format!("{}", lon_val), cx));
+        tz.update(cx, |i, cx| i.set_text(tz_val.to_string(), cx));
         self.city_picker_open = false;
         cx.notify();
     }
@@ -1299,7 +1357,10 @@ fn render_chart_form(
 
     // Header del form: title + botón "Ciudad rápida" con dropdown
     // que autocompleta place/lat/lon/tz al elegir un preset.
-    let picker_open = cx.entity().read(cx).city_picker_open;
+    let (picker_open, atlas_snapshot) = {
+        let me = cx.entity().read(cx);
+        (me.city_picker_open, me.city_atlas.clone())
+    };
     let city_btn = div()
         .id("tts-form-city-btn")
         .px(px(10.0))
@@ -1350,9 +1411,10 @@ fn render_chart_form(
             .flex()
             .flex_col()
             .overflow_y_scroll();
-        for preset in CITY_PRESETS.iter().copied() {
+        for preset in atlas_snapshot.iter().cloned() {
             let row_id: SharedString =
                 SharedString::from(format!("tts-city-{}", preset.name));
+            let label = preset.name.clone();
             popup = popup.child(
                 div()
                     .id(gpui::ElementId::from(row_id))
@@ -1361,9 +1423,9 @@ fn render_chart_form(
                     .text_size(px(11.0))
                     .text_color(theme.fg_text)
                     .hover(|s| s.bg(theme.bg_row_hover))
-                    .child(SharedString::from(preset.name.to_string()))
+                    .child(SharedString::from(label))
                     .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
-                        this.apply_city_preset(preset, cx);
+                        this.apply_city_preset(&preset, cx);
                     })),
             );
         }