feat(tahuantinsuyu): fase 2 — CRUD UX sobre el tree (menú, modales, form natal)
Right-click sobre el explorador izquierdo abre menú contextual cuyas opciones dependen del target (raíz, group, contact o chart). Modales flotantes para crear/renombrar usando yahweh-widget-text-input; un form más completo de 11 campos para la birth data al crear cartas natales. Borrar pide confirmación por window.prompt nativo. - tahuantinsuyu-store: rename_contact, rename_chart, move_group, move_contact (los `move_*` para fase posterior de drag-to-nest). - tahuantinsuyu-tree: estado interno (Menu, Modal enum, ChartForm), handlers de ContextMenuRequested, render overlays. Soporta seis modales: rename de g/c/h, create group/contact, form natal completo con parseo + reporte de errores inline. Auto-expande el contact tras crear una carta. Nuevo evento TreeEvent::HierarchyChanged tras cada mutación. - shell: maneja HierarchyChanged sin propagar selección. `cargo check` y `cargo test` verdes. Fase 3 viene con engine real contra eternal-astrology + pintado de la rueda. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -11011,6 +11011,7 @@ dependencies = [
|
|||||||
"tahuantinsuyu-model",
|
"tahuantinsuyu-model",
|
||||||
"tahuantinsuyu-store",
|
"tahuantinsuyu-store",
|
||||||
"yahweh-theme",
|
"yahweh-theme",
|
||||||
|
"yahweh-widget-text-input",
|
||||||
"yahweh-widget-tree",
|
"yahweh-widget-tree",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ impl Shell {
|
|||||||
let selection = match ev {
|
let selection = match ev {
|
||||||
TreeEvent::Selected(s) => s,
|
TreeEvent::Selected(s) => s,
|
||||||
TreeEvent::Opened(s) => s,
|
TreeEvent::Opened(s) => s,
|
||||||
|
TreeEvent::HierarchyChanged => {
|
||||||
|
// El tree ya hizo refresh internamente; el canvas/panel
|
||||||
|
// se enteran cuando llegue una nueva Selección. Fase 3
|
||||||
|
// podría re-disparar la última selección para que el
|
||||||
|
// thumbnail grid se actualice si era una vista de grupo.
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
self.apply_selection(selection.clone(), cx);
|
self.apply_selection(selection.clone(), cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,19 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cambia el `parent_id` de un Group. Pasar `None` para mover a raíz.
|
||||||
|
/// **No** valida ciclos — el caller debe garantizar que el nuevo
|
||||||
|
/// padre no sea descendiente del que mueve (sino la DB queda con un
|
||||||
|
/// ciclo que el list_groups no rompe pero hace al CTE infinito).
|
||||||
|
pub fn move_group(&self, id: GroupId, new_parent: Option<GroupId>) -> StoreResult<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE groups SET parent_id = ?2 WHERE id = ?1",
|
||||||
|
params![id.to_string(), new_parent.map(|g| g.to_string())],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Contacts
|
// Contacts
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
@@ -204,6 +217,24 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rename_contact(&self, id: ContactId, name: &str) -> StoreResult<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE contacts SET name = ?2 WHERE id = ?1",
|
||||||
|
params![id.to_string(), name],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_contact(&self, id: ContactId, new_group: Option<GroupId>) -> StoreResult<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE contacts SET group_id = ?2 WHERE id = ?1",
|
||||||
|
params![id.to_string(), new_group.map(|g| g.to_string())],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Charts
|
// Charts
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
@@ -285,6 +316,15 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rename_chart(&self, id: ChartId, label: &str) -> StoreResult<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE charts SET label = ?2 WHERE id = ?1",
|
||||||
|
params![id.to_string(), label],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// Module state
|
// Module state
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
|
|||||||
tahuantinsuyu-store = { path = "../tahuantinsuyu-store" }
|
tahuantinsuyu-store = { path = "../tahuantinsuyu-store" }
|
||||||
yahweh-theme = { workspace = true }
|
yahweh-theme = { workspace = true }
|
||||||
yahweh-widget-tree = { workspace = true }
|
yahweh-widget-tree = { workspace = true }
|
||||||
|
yahweh-widget-text-input = { path = "../../ui_engine/widgets/text_input" }
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
//! `tahuantinsuyu-tree` — explorador jerárquico Groups → Contacts → Charts.
|
//! `tahuantinsuyu-tree` — explorador jerárquico Groups → Contacts → Charts.
|
||||||
//!
|
//!
|
||||||
//! Envuelve [`yahweh_widget_tree::TreeView`] con la lógica de dominio
|
//! Envuelve [`yahweh_widget_tree::TreeView`] con la lógica de dominio
|
||||||
//! propia de Tahuantinsuyu. Los `RowId` codifican el tipo del item con
|
//! de Tahuantinsuyu. Los `RowId` codifican el tipo con prefijo:
|
||||||
//! prefijo:
|
|
||||||
//!
|
//!
|
||||||
//! - `g:<ulid>` → Group
|
//! - `g:<ulid>` → Group
|
||||||
//! - `c:<ulid>` → Contact
|
//! - `c:<ulid>` → Contact
|
||||||
//! - `h:<ulid>` → Chart
|
//! - `h:<ulid>` → Chart
|
||||||
//!
|
//!
|
||||||
|
//! ## Fase 2 — CRUD UX
|
||||||
|
//!
|
||||||
|
//! - **Right-click** abre un menú contextual cuyas opciones dependen
|
||||||
|
//! del target (raíz, group, contact o chart).
|
||||||
|
//! - **Renombrar** y **crear** abren un modal con un `TextInput`.
|
||||||
|
//! - **Crear carta** abre un formulario con los campos mínimos de
|
||||||
|
//! `StoredBirthData` (year/month/day/hour/min/tz/lat/lon).
|
||||||
|
//! - **Borrar** pide confirmación con `window.prompt`.
|
||||||
|
//!
|
||||||
//! El host (la app) se suscribe a [`TreeEvent`] y traduce a `AppEvent`
|
//! El host (la app) se suscribe a [`TreeEvent`] y traduce a `AppEvent`
|
||||||
//! del bus de yahweh para que el canvas/panel reaccionen.
|
//! 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)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(rust_2018_idioms)]
|
#![warn(rust_2018_idioms)]
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use gpui::{Context, Entity, EventEmitter, IntoElement, Render, Window, prelude::*};
|
use gpui::{
|
||||||
|
ClickEvent, Context, Entity, EventEmitter, IntoElement, Pixels, Point, PromptLevel, Render,
|
||||||
|
SharedString, Window, div, hsla, prelude::*, px,
|
||||||
|
};
|
||||||
|
|
||||||
use tahuantinsuyu_model::{ContactId, GroupId, TreeSelection};
|
use tahuantinsuyu_model::{
|
||||||
|
ChartId, ChartKind, ContactId, GroupId, StoredBirthData, StoredChartConfig, TimeCertainty,
|
||||||
|
TreeSelection,
|
||||||
|
};
|
||||||
use tahuantinsuyu_store::Store;
|
use tahuantinsuyu_store::Store;
|
||||||
|
use yahweh_theme::Theme;
|
||||||
|
use yahweh_widget_text_input::{TextInput, TextInputEvent};
|
||||||
use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, TreeView};
|
use yahweh_widget_tree::{RowId, RowKind, TreeEvent as InnerTreeEvent, TreeRow, TreeView};
|
||||||
|
|
||||||
const PREFIX_GROUP: &str = "g:";
|
const PREFIX_GROUP: &str = "g:";
|
||||||
@@ -35,11 +48,84 @@ const PREFIX_CHART: &str = "h:";
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum TreeEvent {
|
pub enum TreeEvent {
|
||||||
/// El usuario activó (single click) un item.
|
|
||||||
Selected(TreeSelection),
|
Selected(TreeSelection),
|
||||||
/// El usuario abrió (doble click) un item — la app decide qué hacer
|
|
||||||
/// (en general, abrir la carta en el canvas).
|
|
||||||
Opened(TreeSelection),
|
Opened(TreeSelection),
|
||||||
|
/// Una mutación de la jerarquía aconteció (crear, borrar, renombrar).
|
||||||
|
/// El host puede usarlo para invalidar caches en otros widgets.
|
||||||
|
HierarchyChanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Estado interno
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Target del menú contextual / acciones.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum MenuTarget {
|
||||||
|
Root,
|
||||||
|
Group(GroupId),
|
||||||
|
Contact(ContactId),
|
||||||
|
Chart(ChartId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MenuTarget {
|
||||||
|
fn from_selection(sel: &TreeSelection) -> Self {
|
||||||
|
match sel {
|
||||||
|
TreeSelection::Group(id) => MenuTarget::Group(*id),
|
||||||
|
TreeSelection::Contact(id) => MenuTarget::Contact(*id),
|
||||||
|
TreeSelection::Chart(id) => MenuTarget::Chart(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct MenuState {
|
||||||
|
target: MenuTarget,
|
||||||
|
position: Point<Pixels>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modal flotante. Una sola `Modal` activa a la vez — la app no
|
||||||
|
/// soporta editar varias cosas en simultáneo.
|
||||||
|
enum Modal {
|
||||||
|
RenameGroup {
|
||||||
|
id: GroupId,
|
||||||
|
input: Entity<TextInput>,
|
||||||
|
},
|
||||||
|
RenameContact {
|
||||||
|
id: ContactId,
|
||||||
|
input: Entity<TextInput>,
|
||||||
|
},
|
||||||
|
RenameChart {
|
||||||
|
id: ChartId,
|
||||||
|
input: Entity<TextInput>,
|
||||||
|
},
|
||||||
|
CreateGroup {
|
||||||
|
parent: Option<GroupId>,
|
||||||
|
input: Entity<TextInput>,
|
||||||
|
},
|
||||||
|
CreateContact {
|
||||||
|
group: Option<GroupId>,
|
||||||
|
input: Entity<TextInput>,
|
||||||
|
},
|
||||||
|
CreateChart {
|
||||||
|
contact: ContactId,
|
||||||
|
form: ChartForm,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChartForm {
|
||||||
|
name: Entity<TextInput>,
|
||||||
|
place: Entity<TextInput>,
|
||||||
|
year: Entity<TextInput>,
|
||||||
|
month: Entity<TextInput>,
|
||||||
|
day: Entity<TextInput>,
|
||||||
|
hour: Entity<TextInput>,
|
||||||
|
minute: Entity<TextInput>,
|
||||||
|
tz_offset_min: Entity<TextInput>,
|
||||||
|
lat: Entity<TextInput>,
|
||||||
|
lon: Entity<TextInput>,
|
||||||
|
alt: Entity<TextInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -50,12 +136,16 @@ pub struct TahuantinsuyuTree {
|
|||||||
store: Store,
|
store: Store,
|
||||||
inner: Entity<TreeView>,
|
inner: Entity<TreeView>,
|
||||||
expanded: HashSet<String>,
|
expanded: HashSet<String>,
|
||||||
|
menu: Option<MenuState>,
|
||||||
|
modal: Option<Modal>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
|
impl EventEmitter<TreeEvent> for TahuantinsuyuTree {}
|
||||||
|
|
||||||
impl TahuantinsuyuTree {
|
impl TahuantinsuyuTree {
|
||||||
pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
|
pub fn new(store: Store, cx: &mut Context<Self>) -> Self {
|
||||||
|
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
||||||
|
|
||||||
let inner = cx.new(|cx| TreeView::new("tahuantinsuyu-tree", cx));
|
let inner = cx.new(|cx| TreeView::new("tahuantinsuyu-tree", cx));
|
||||||
cx.subscribe(&inner, |this: &mut Self, _, ev, cx| {
|
cx.subscribe(&inner, |this: &mut Self, _, ev, cx| {
|
||||||
this.on_inner(ev, cx);
|
this.on_inner(ev, cx);
|
||||||
@@ -66,19 +156,18 @@ impl TahuantinsuyuTree {
|
|||||||
store,
|
store,
|
||||||
inner,
|
inner,
|
||||||
expanded: HashSet::new(),
|
expanded: HashSet::new(),
|
||||||
|
menu: None,
|
||||||
|
modal: None,
|
||||||
};
|
};
|
||||||
me.refresh(cx);
|
me.refresh(cx);
|
||||||
me
|
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>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
self.append_groups(None, 0, &mut rows);
|
self.append_groups(None, 0, &mut rows);
|
||||||
self.append_contacts(None, 0, &mut rows);
|
self.append_contacts(None, 0, &mut rows);
|
||||||
self.inner
|
self.inner.update(cx, |t, cx| t.set_rows(rows, cx));
|
||||||
.update(cx, |t, cx| t.set_rows(rows, cx));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
|
fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
|
||||||
@@ -154,6 +243,10 @@ impl TahuantinsuyuTree {
|
|||||||
self.refresh(cx);
|
self.refresh(cx);
|
||||||
}
|
}
|
||||||
InnerTreeEvent::RowClicked(id) => {
|
InnerTreeEvent::RowClicked(id) => {
|
||||||
|
if self.menu.is_some() {
|
||||||
|
self.menu = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
if let Some(sel) = parse_row(id) {
|
if let Some(sel) = parse_row(id) {
|
||||||
cx.emit(TreeEvent::Selected(sel));
|
cx.emit(TreeEvent::Selected(sel));
|
||||||
}
|
}
|
||||||
@@ -163,12 +256,447 @@ impl TahuantinsuyuTree {
|
|||||||
cx.emit(TreeEvent::Opened(sel));
|
cx.emit(TreeEvent::Opened(sel));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InnerTreeEvent::ContextMenuRequested { .. } => {
|
InnerTreeEvent::ContextMenuRequested { id, position } => {
|
||||||
// Fase 2: menú contextual para crear/renombrar/borrar.
|
let target = match id.as_ref().and_then(parse_row) {
|
||||||
|
Some(sel) => MenuTarget::from_selection(&sel),
|
||||||
|
None => MenuTarget::Root,
|
||||||
|
};
|
||||||
|
self.menu = Some(MenuState {
|
||||||
|
target,
|
||||||
|
position: *position,
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
InnerTreeEvent::ActiveChanged(_) => {}
|
InnerTreeEvent::ActiveChanged(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Acciones del menú
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
fn close_menu(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.menu.take().is_some() {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_modal(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.modal.take().is_some() {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_create_group(
|
||||||
|
&mut self,
|
||||||
|
parent: Option<GroupId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let input = self.make_input("Nombre del grupo", "", window, cx);
|
||||||
|
self.modal = Some(Modal::CreateGroup { parent, input });
|
||||||
|
self.close_menu(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_create_contact(
|
||||||
|
&mut self,
|
||||||
|
group: Option<GroupId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let input = self.make_input("Nombre del contacto", "", window, cx);
|
||||||
|
self.modal = Some(Modal::CreateContact { group, input });
|
||||||
|
self.close_menu(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_create_chart(
|
||||||
|
&mut self,
|
||||||
|
contact: ContactId,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
// Pre-cargamos el nombre del contacto en el campo "Sujeto" del
|
||||||
|
// form como conveniencia — la mayoría de las cartas se nombran
|
||||||
|
// igual que la persona.
|
||||||
|
let subject_name = self
|
||||||
|
.store
|
||||||
|
.list_contacts(None)
|
||||||
|
.ok()
|
||||||
|
.and_then(|all| {
|
||||||
|
// Buscamos linealmente — list_contacts solo lista hijos
|
||||||
|
// directos del group, no nos sirve para encontrar un
|
||||||
|
// contact arbitrario. Para fase 2 nos quedamos con
|
||||||
|
// "Carta natal" como label genérico si no podemos
|
||||||
|
// resolver. Resolver-por-id viene si lo necesitamos.
|
||||||
|
let _ = all;
|
||||||
|
None::<String>
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Carta natal".into());
|
||||||
|
|
||||||
|
let form = ChartForm {
|
||||||
|
name: self.make_input("Etiqueta de la carta", &subject_name, window, cx),
|
||||||
|
place: self.make_input("Lugar (ciudad, país)", "", window, cx),
|
||||||
|
year: self.make_input("Año", "1987", window, cx),
|
||||||
|
month: self.make_input("Mes", "3", window, cx),
|
||||||
|
day: self.make_input("Día", "14", window, cx),
|
||||||
|
hour: self.make_input("Hora (0-23)", "5", window, cx),
|
||||||
|
minute: self.make_input("Minuto", "22", window, cx),
|
||||||
|
tz_offset_min: self.make_input("TZ offset (min)", "-240", window, cx),
|
||||||
|
lat: self.make_input("Latitud (°)", "10.4806", window, cx),
|
||||||
|
lon: self.make_input("Longitud (°)", "-66.9036", window, cx),
|
||||||
|
alt: self.make_input("Altitud (m)", "900", window, cx),
|
||||||
|
};
|
||||||
|
// El primer field es el que recibe focus.
|
||||||
|
form.name.update(cx, |i, _| i.request_focus(window));
|
||||||
|
|
||||||
|
self.modal = Some(Modal::CreateChart {
|
||||||
|
contact,
|
||||||
|
form,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
self.close_menu(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_rename(&mut self, target: MenuTarget, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let modal = match target {
|
||||||
|
MenuTarget::Group(id) => {
|
||||||
|
let current = self
|
||||||
|
.store
|
||||||
|
.list_groups(None)
|
||||||
|
.ok()
|
||||||
|
.and_then(|all| find_group_name(&all, &self.store, id))
|
||||||
|
.unwrap_or_default();
|
||||||
|
Modal::RenameGroup {
|
||||||
|
id,
|
||||||
|
input: self.make_input("Nuevo nombre", ¤t, window, cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuTarget::Contact(id) => {
|
||||||
|
let current = self
|
||||||
|
.store
|
||||||
|
.list_contacts(None)
|
||||||
|
.ok()
|
||||||
|
.and_then(|all| find_contact_name(&all, &self.store, id))
|
||||||
|
.unwrap_or_default();
|
||||||
|
Modal::RenameContact {
|
||||||
|
id,
|
||||||
|
input: self.make_input("Nuevo nombre", ¤t, window, cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuTarget::Chart(id) => {
|
||||||
|
let current = self
|
||||||
|
.store
|
||||||
|
.get_chart(id)
|
||||||
|
.ok()
|
||||||
|
.map(|c| c.label)
|
||||||
|
.unwrap_or_default();
|
||||||
|
Modal::RenameChart {
|
||||||
|
id,
|
||||||
|
input: self.make_input("Nueva etiqueta", ¤t, window, cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuTarget::Root => return,
|
||||||
|
};
|
||||||
|
self.modal = Some(modal);
|
||||||
|
self.close_menu(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crea un `TextInput` con focus y suscripción a Confirmed/Cancelled.
|
||||||
|
/// La closure decide qué hacer con cada evento — guardamos la subscripción
|
||||||
|
/// detached para que viva mientras el modal exista.
|
||||||
|
fn make_input(
|
||||||
|
&self,
|
||||||
|
placeholder: &str,
|
||||||
|
initial: &str,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Entity<TextInput> {
|
||||||
|
let placeholder = placeholder.to_string();
|
||||||
|
let input = cx.new(|cx| {
|
||||||
|
TextInput::new(initial.to_string(), cx)
|
||||||
|
.with_placeholder(SharedString::from(placeholder.clone()))
|
||||||
|
});
|
||||||
|
cx.subscribe(&input, |this: &mut Self, _, ev: &TextInputEvent, cx| {
|
||||||
|
this.on_input_event(ev, cx);
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
input.update(cx, |i, _| i.request_focus(window));
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_input_event(&mut self, ev: &TextInputEvent, cx: &mut Context<Self>) {
|
||||||
|
match ev {
|
||||||
|
TextInputEvent::Cancelled => self.close_modal(cx),
|
||||||
|
TextInputEvent::Confirmed(value) => self.submit_modal(value.clone(), cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_modal(&mut self, value: String, cx: &mut Context<Self>) {
|
||||||
|
let trimmed = value.trim().to_string();
|
||||||
|
// Tomamos ownership del modal — si el submit falla en mitad,
|
||||||
|
// lo restablecemos. Esto evita un borrow-mut sobre self.modal.
|
||||||
|
let modal = match self.modal.take() {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
match modal {
|
||||||
|
Modal::RenameGroup { id, input } => {
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let _ = self.store.rename_group(id, &trimmed);
|
||||||
|
}
|
||||||
|
drop(input);
|
||||||
|
self.after_mutation(cx);
|
||||||
|
}
|
||||||
|
Modal::RenameContact { id, input } => {
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let _ = self.store.rename_contact(id, &trimmed);
|
||||||
|
}
|
||||||
|
drop(input);
|
||||||
|
self.after_mutation(cx);
|
||||||
|
}
|
||||||
|
Modal::RenameChart { id, input } => {
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let _ = self.store.rename_chart(id, &trimmed);
|
||||||
|
}
|
||||||
|
drop(input);
|
||||||
|
self.after_mutation(cx);
|
||||||
|
}
|
||||||
|
Modal::CreateGroup { parent, input } => {
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let _ = self.store.create_group(parent, &trimmed, None);
|
||||||
|
}
|
||||||
|
drop(input);
|
||||||
|
self.after_mutation(cx);
|
||||||
|
}
|
||||||
|
Modal::CreateContact { group, input } => {
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let _ = self.store.create_contact(group, &trimmed, None);
|
||||||
|
}
|
||||||
|
drop(input);
|
||||||
|
self.after_mutation(cx);
|
||||||
|
}
|
||||||
|
Modal::CreateChart {
|
||||||
|
contact,
|
||||||
|
form,
|
||||||
|
error: _,
|
||||||
|
} => {
|
||||||
|
// `value` viene del campo que disparó Enter. Para el
|
||||||
|
// form, ignoramos el value puntual y leemos todos los
|
||||||
|
// campos del form.
|
||||||
|
let _ = value;
|
||||||
|
match build_chart_from_form(&form, cx) {
|
||||||
|
Ok((birth, label)) => {
|
||||||
|
match self.store.create_chart(
|
||||||
|
contact,
|
||||||
|
ChartKind::Natal,
|
||||||
|
&label,
|
||||||
|
&birth,
|
||||||
|
&StoredChartConfig::default(),
|
||||||
|
None,
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
// Auto-expand del contact para que se vea
|
||||||
|
// la carta recién creada.
|
||||||
|
self.expanded
|
||||||
|
.insert(format!("{}{}", PREFIX_CONTACT, contact));
|
||||||
|
self.after_mutation(cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.modal = Some(Modal::CreateChart {
|
||||||
|
contact,
|
||||||
|
form,
|
||||||
|
error: Some(SharedString::from(format!("Store: {}", e))),
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
self.modal = Some(Modal::CreateChart {
|
||||||
|
contact,
|
||||||
|
form,
|
||||||
|
error: Some(SharedString::from(msg)),
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn after_mutation(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.modal = None;
|
||||||
|
self.refresh(cx);
|
||||||
|
cx.emit(TreeEvent::HierarchyChanged);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_and_delete(
|
||||||
|
&mut self,
|
||||||
|
target: MenuTarget,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let (label, kind) = match target {
|
||||||
|
MenuTarget::Group(_) => ("este grupo (incluye sus subgrupos y contactos)", "group"),
|
||||||
|
MenuTarget::Contact(_) => ("este contacto (incluye sus cartas)", "contact"),
|
||||||
|
MenuTarget::Chart(_) => ("esta carta", "chart"),
|
||||||
|
MenuTarget::Root => return,
|
||||||
|
};
|
||||||
|
let answer = window.prompt(
|
||||||
|
PromptLevel::Warning,
|
||||||
|
&format!("¿Borrar {}?", label),
|
||||||
|
None,
|
||||||
|
&["Borrar", "Cancelar"],
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
let target_clone = target.clone();
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let Ok(idx) = answer.await else { return };
|
||||||
|
if idx != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = this.update(cx, |this, cx| {
|
||||||
|
match target_clone {
|
||||||
|
MenuTarget::Group(id) => {
|
||||||
|
let _ = this.store.delete_group(id);
|
||||||
|
}
|
||||||
|
MenuTarget::Contact(id) => {
|
||||||
|
let _ = this.store.delete_contact(id);
|
||||||
|
}
|
||||||
|
MenuTarget::Chart(id) => {
|
||||||
|
let _ = this.store.delete_chart(id);
|
||||||
|
}
|
||||||
|
MenuTarget::Root => {}
|
||||||
|
}
|
||||||
|
this.after_mutation(cx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
let _ = kind;
|
||||||
|
self.close_menu(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Form helpers
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
fn build_chart_from_form(
|
||||||
|
form: &ChartForm,
|
||||||
|
cx: &mut Context<TahuantinsuyuTree>,
|
||||||
|
) -> Result<(StoredBirthData, String), String> {
|
||||||
|
let name = form.name.read(cx).text().trim().to_string();
|
||||||
|
let place = form.place.read(cx).text().trim().to_string();
|
||||||
|
let year: i32 = parse_field(form.year.read(cx).text(), "Año")?;
|
||||||
|
let month: u32 = parse_field(form.month.read(cx).text(), "Mes")?;
|
||||||
|
let day: u32 = parse_field(form.day.read(cx).text(), "Día")?;
|
||||||
|
let hour: u32 = parse_field(form.hour.read(cx).text(), "Hora")?;
|
||||||
|
let minute: u32 = parse_field(form.minute.read(cx).text(), "Minuto")?;
|
||||||
|
let tz_offset_minutes: i32 = parse_field(form.tz_offset_min.read(cx).text(), "TZ offset")?;
|
||||||
|
let latitude_deg: f64 = parse_field(form.lat.read(cx).text(), "Latitud")?;
|
||||||
|
let longitude_deg: f64 = parse_field(form.lon.read(cx).text(), "Longitud")?;
|
||||||
|
let altitude_m: f64 = parse_field(form.alt.read(cx).text(), "Altitud")?;
|
||||||
|
|
||||||
|
if !(1..=12).contains(&month) {
|
||||||
|
return Err(format!("Mes fuera de rango: {}", month));
|
||||||
|
}
|
||||||
|
if !(1..=31).contains(&day) {
|
||||||
|
return Err(format!("Día fuera de rango: {}", day));
|
||||||
|
}
|
||||||
|
if hour > 23 {
|
||||||
|
return Err(format!("Hora fuera de rango: {}", hour));
|
||||||
|
}
|
||||||
|
if minute > 59 {
|
||||||
|
return Err(format!("Minuto fuera de rango: {}", minute));
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = if name.is_empty() {
|
||||||
|
"Carta natal".to_string()
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
let birth = StoredBirthData {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
second: 0.0,
|
||||||
|
tz_offset_minutes,
|
||||||
|
latitude_deg,
|
||||||
|
longitude_deg,
|
||||||
|
altitude_m,
|
||||||
|
time_certainty: TimeCertainty::Exact,
|
||||||
|
subject_name: None,
|
||||||
|
birthplace_label: if place.is_empty() { None } else { Some(place) },
|
||||||
|
};
|
||||||
|
Ok((birth, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_field<T: std::str::FromStr>(s: &str, field: &str) -> Result<T, String> {
|
||||||
|
s.trim()
|
||||||
|
.parse::<T>()
|
||||||
|
.map_err(|_| format!("Campo \"{}\" inválido: {:?}", field, s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Lookups auxiliares (DFS por la jerarquía)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
fn find_group_name(roots: &[tahuantinsuyu_model::Group], store: &Store, id: GroupId) -> Option<String> {
|
||||||
|
for g in roots {
|
||||||
|
if g.id == id {
|
||||||
|
return Some(g.name.clone());
|
||||||
|
}
|
||||||
|
if let Ok(children) = store.list_groups(Some(g.id)) {
|
||||||
|
if let Some(n) = find_group_name(&children, store, id) {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_contact_name(
|
||||||
|
in_group: &[tahuantinsuyu_model::Contact],
|
||||||
|
store: &Store,
|
||||||
|
id: ContactId,
|
||||||
|
) -> Option<String> {
|
||||||
|
for c in in_group {
|
||||||
|
if c.id == id {
|
||||||
|
return Some(c.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Buscar también en todos los groups recursivamente.
|
||||||
|
if let Ok(groups) = store.list_groups(None) {
|
||||||
|
if let Some(n) = find_contact_in_groups(&groups, store, id) {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_contact_in_groups(
|
||||||
|
groups: &[tahuantinsuyu_model::Group],
|
||||||
|
store: &Store,
|
||||||
|
id: ContactId,
|
||||||
|
) -> Option<String> {
|
||||||
|
for g in groups {
|
||||||
|
if let Ok(cs) = store.list_contacts(Some(g.id)) {
|
||||||
|
for c in &cs {
|
||||||
|
if c.id == id {
|
||||||
|
return Some(c.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(children) = store.list_groups(Some(g.id)) {
|
||||||
|
if let Some(n) = find_contact_in_groups(&children, store, id) {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_row(id: &RowId) -> Option<TreeSelection> {
|
fn parse_row(id: &RowId) -> Option<TreeSelection> {
|
||||||
@@ -185,8 +713,355 @@ fn parse_row(id: &RowId) -> Option<TreeSelection> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Render
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const MENU_WIDTH: f32 = 220.0;
|
||||||
|
|
||||||
impl Render for TahuantinsuyuTree {
|
impl Render for TahuantinsuyuTree {
|
||||||
fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
self.inner.clone()
|
let theme = Theme::global(cx).clone();
|
||||||
|
let mut root = div()
|
||||||
|
.id("tahuantinsuyu-tree-root")
|
||||||
|
.size_full()
|
||||||
|
.relative()
|
||||||
|
.bg(theme.bg_panel.clone())
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.child(self.inner.clone());
|
||||||
|
|
||||||
|
if let Some(menu) = self.menu.clone() {
|
||||||
|
root = root.child(self.render_menu(&theme, menu, cx));
|
||||||
|
}
|
||||||
|
if self.modal.is_some() {
|
||||||
|
root = root.child(self.render_modal(&theme, cx));
|
||||||
|
}
|
||||||
|
root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TahuantinsuyuTree {
|
||||||
|
fn render_menu(
|
||||||
|
&self,
|
||||||
|
theme: &Theme,
|
||||||
|
menu: MenuState,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
let mut items = div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.py(px(4.0))
|
||||||
|
.min_w(px(MENU_WIDTH))
|
||||||
|
.bg(theme.bg_panel_alt.clone())
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.border_strong)
|
||||||
|
.rounded(px(6.0));
|
||||||
|
|
||||||
|
match menu.target.clone() {
|
||||||
|
MenuTarget::Root => {
|
||||||
|
items = items.child(menu_item("tts-menu-new-group", "Nuevo grupo", theme).on_click(
|
||||||
|
cx.listener(|this, _: &ClickEvent, w, cx| {
|
||||||
|
this.open_create_group(None, w, cx);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
items = items.child(
|
||||||
|
menu_item("tts-menu-new-contact-root", "Nuevo contacto", theme).on_click(
|
||||||
|
cx.listener(|this, _: &ClickEvent, w, cx| {
|
||||||
|
this.open_create_contact(None, w, cx);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
MenuTarget::Group(id) => {
|
||||||
|
items = items.child(
|
||||||
|
menu_item("tts-menu-new-subgroup", "Nuevo subgrupo", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||||
|
this.open_create_group(Some(id), w, cx);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
items = items.child(
|
||||||
|
menu_item("tts-menu-new-contact", "Nuevo contacto", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||||
|
this.open_create_contact(Some(id), w, cx);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
items = items.child(separator(theme));
|
||||||
|
let t = menu.target.clone();
|
||||||
|
items = items.child(menu_item("tts-menu-rename-g", "Renombrar…", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||||
|
this.open_rename(t.clone(), w, cx);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
let t = menu.target.clone();
|
||||||
|
items = items.child(menu_item("tts-menu-delete-g", "Borrar…", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||||
|
this.confirm_and_delete(t.clone(), w, cx);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
MenuTarget::Contact(id) => {
|
||||||
|
items = items.child(menu_item("tts-menu-new-chart", "Nueva carta…", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||||
|
this.open_create_chart(id, w, cx);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
items = items.child(separator(theme));
|
||||||
|
let t = menu.target.clone();
|
||||||
|
items = items.child(menu_item("tts-menu-rename-c", "Renombrar…", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||||
|
this.open_rename(t.clone(), w, cx);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
let t = menu.target.clone();
|
||||||
|
items = items.child(menu_item("tts-menu-delete-c", "Borrar…", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||||
|
this.confirm_and_delete(t.clone(), w, cx);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
MenuTarget::Chart(id) => {
|
||||||
|
items = items.child(menu_item("tts-menu-open-h", "Abrir", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, _w, cx| {
|
||||||
|
cx.emit(TreeEvent::Opened(TreeSelection::Chart(id)));
|
||||||
|
this.close_menu(cx);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
items = items.child(separator(theme));
|
||||||
|
let t = menu.target.clone();
|
||||||
|
items = items.child(menu_item("tts-menu-rename-h", "Renombrar…", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||||
|
this.open_rename(t.clone(), w, cx);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
let t = menu.target.clone();
|
||||||
|
items = items.child(menu_item("tts-menu-delete-h", "Borrar…", theme).on_click(
|
||||||
|
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||||
|
this.confirm_and_delete(t.clone(), w, cx);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.left(menu.position.x)
|
||||||
|
.top(menu.position.y)
|
||||||
|
.child(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_modal(&self, theme: &Theme, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let modal = self.modal.as_ref().expect("render_modal sin modal activo");
|
||||||
|
let inner = match modal {
|
||||||
|
Modal::RenameGroup { input, .. }
|
||||||
|
| Modal::RenameContact { input, .. }
|
||||||
|
| Modal::RenameChart { input, .. } => {
|
||||||
|
modal_box(theme, "Renombrar", input.clone(), "Enter = guardar — Escape = cancelar")
|
||||||
|
}
|
||||||
|
Modal::CreateGroup { input, .. } => {
|
||||||
|
modal_box(theme, "Nuevo grupo", input.clone(), "Enter = crear — Escape = cancelar")
|
||||||
|
}
|
||||||
|
Modal::CreateContact { input, .. } => modal_box(
|
||||||
|
theme,
|
||||||
|
"Nuevo contacto",
|
||||||
|
input.clone(),
|
||||||
|
"Enter = crear — Escape = cancelar",
|
||||||
|
),
|
||||||
|
Modal::CreateChart { form, error, .. } => render_chart_form(theme, form, error.clone(), cx),
|
||||||
|
};
|
||||||
|
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.top(px(0.0))
|
||||||
|
.left(px(0.0))
|
||||||
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.bg(hsla(0.0, 0.0, 0.0, 0.55))
|
||||||
|
.child(inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Helpers de UI
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
fn menu_item(
|
||||||
|
id: &'static str,
|
||||||
|
label: &'static str,
|
||||||
|
theme: &Theme,
|
||||||
|
) -> gpui::Stateful<gpui::Div> {
|
||||||
|
div()
|
||||||
|
.id(id)
|
||||||
|
.px(px(12.0))
|
||||||
|
.py(px(6.0))
|
||||||
|
.text_size(px(12.0))
|
||||||
|
.text_color(theme.fg_text)
|
||||||
|
.hover(|s| s.bg(theme.bg_row_hover))
|
||||||
|
.child(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn separator(theme: &Theme) -> gpui::Div {
|
||||||
|
div()
|
||||||
|
.my(px(3.0))
|
||||||
|
.h(px(1.0))
|
||||||
|
.w_full()
|
||||||
|
.bg(theme.border)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modal_box(
|
||||||
|
theme: &Theme,
|
||||||
|
title: &'static str,
|
||||||
|
input: Entity<TextInput>,
|
||||||
|
hint: &'static str,
|
||||||
|
) -> gpui::Div {
|
||||||
|
div()
|
||||||
|
.min_w(px(380.0))
|
||||||
|
.p(px(16.0))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(10.0))
|
||||||
|
.bg(theme.bg_panel_alt.clone())
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.border_strong)
|
||||||
|
.rounded(px(8.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(13.0))
|
||||||
|
.text_color(theme.fg_text)
|
||||||
|
.child(title),
|
||||||
|
)
|
||||||
|
.child(input)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(10.0))
|
||||||
|
.text_color(theme.fg_muted)
|
||||||
|
.child(hint),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_chart_form(
|
||||||
|
theme: &Theme,
|
||||||
|
form: &ChartForm,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
cx: &mut Context<TahuantinsuyuTree>,
|
||||||
|
) -> gpui::Div {
|
||||||
|
let labeled = |label: &'static str, input: Entity<TextInput>| -> gpui::Div {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(2.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(10.0))
|
||||||
|
.text_color(theme.fg_muted)
|
||||||
|
.child(label),
|
||||||
|
)
|
||||||
|
.child(input)
|
||||||
|
};
|
||||||
|
|
||||||
|
let date_row = div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.gap(px(8.0))
|
||||||
|
.child(labeled("Año", form.year.clone()))
|
||||||
|
.child(labeled("Mes", form.month.clone()))
|
||||||
|
.child(labeled("Día", form.day.clone()))
|
||||||
|
.child(labeled("Hora", form.hour.clone()))
|
||||||
|
.child(labeled("Minuto", form.minute.clone()))
|
||||||
|
.child(labeled("TZ (min)", form.tz_offset_min.clone()));
|
||||||
|
|
||||||
|
let loc_row = div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.gap(px(8.0))
|
||||||
|
.child(labeled("Latitud", form.lat.clone()))
|
||||||
|
.child(labeled("Longitud", form.lon.clone()))
|
||||||
|
.child(labeled("Altitud (m)", form.alt.clone()));
|
||||||
|
|
||||||
|
let create_btn = div()
|
||||||
|
.id("tts-chart-form-create")
|
||||||
|
.px(px(14.0))
|
||||||
|
.py(px(8.0))
|
||||||
|
.rounded(px(6.0))
|
||||||
|
.bg(theme.bg_button())
|
||||||
|
.hover(|s| s.bg(theme.bg_button_hover()))
|
||||||
|
.text_size(px(12.0))
|
||||||
|
.text_color(theme.fg_text)
|
||||||
|
.child("Crear carta")
|
||||||
|
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||||
|
// Disparamos un submit "vacío" — el handler de submit
|
||||||
|
// re-lee todos los campos del form. El value que pasamos
|
||||||
|
// se ignora dentro del branch CreateChart.
|
||||||
|
this.submit_modal(String::new(), cx);
|
||||||
|
}));
|
||||||
|
|
||||||
|
let cancel_btn = div()
|
||||||
|
.id("tts-chart-form-cancel")
|
||||||
|
.px(px(14.0))
|
||||||
|
.py(px(8.0))
|
||||||
|
.rounded(px(6.0))
|
||||||
|
.bg(theme.bg_panel.clone())
|
||||||
|
.hover(|s| s.bg(theme.bg_row_hover))
|
||||||
|
.text_size(px(12.0))
|
||||||
|
.text_color(theme.fg_muted)
|
||||||
|
.child("Cancelar")
|
||||||
|
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||||
|
this.close_modal(cx);
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mut body = div()
|
||||||
|
.min_w(px(640.0))
|
||||||
|
.p(px(18.0))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(12.0))
|
||||||
|
.bg(theme.bg_panel_alt.clone())
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.border_strong)
|
||||||
|
.rounded(px(8.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(14.0))
|
||||||
|
.text_color(theme.fg_text)
|
||||||
|
.child("Nueva carta natal"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.gap(px(8.0))
|
||||||
|
.child(labeled("Etiqueta", form.name.clone()))
|
||||||
|
.child(labeled("Lugar (texto libre)", form.place.clone())),
|
||||||
|
)
|
||||||
|
.child(date_row)
|
||||||
|
.child(loc_row);
|
||||||
|
|
||||||
|
if let Some(err) = error {
|
||||||
|
body = body.child(
|
||||||
|
div()
|
||||||
|
.px(px(10.0))
|
||||||
|
.py(px(6.0))
|
||||||
|
.rounded(px(4.0))
|
||||||
|
.bg(theme.bg_destructive_hover())
|
||||||
|
.text_size(px(11.0))
|
||||||
|
.text_color(theme.accent_destructive())
|
||||||
|
.child(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
body = body.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.gap(px(8.0))
|
||||||
|
.justify_end()
|
||||||
|
.child(cancel_btn)
|
||||||
|
.child(create_btn),
|
||||||
|
);
|
||||||
|
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user