From e09207b152419ca29accb3d3eb721990e49290ab Mon Sep 17 00:00:00 2001 From: sergio Date: Mon, 18 May 2026 15:10:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(tahuantinsuyu):=20UX=20pass=20=E2=80=94=20?= =?UTF-8?q?splitter,=20light=20wheel,=20scroll,=20zoom/pan,=20dock=20later?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seis fixes derivados de testing real, ordenados por costo: - Splitter (yahweh-widget-splitter): `flex-basis: 0` por item para que el ratio flex-grow se respete sin importar el min-content de los hijos. Sin esto, al cambiar el canvas de Empty→Wheel (WHEEL_SIZE fijo de 580px) la suma de basis excedía el contenedor y flexbox abandonaba el ratio 1:4, aplastando el tree a 0px (síntoma reportado: "el tree desaparece al seleccionar carta"). También se amplió la hit-zone del divider de 4px a 12px manteniendo una franja visual de 4px centrada — la zona de pointer-capture y cursor es ahora mucho más generosa, el visual sigue fino. - Light mode wheel (tahuantinsuyu-canvas + tahuantinsuyu-theme): el gradient del fondo del wheel pasa de alphas 0.06/0.03 (invisibles contra fondo claro) a 0.18/0.10 cuando el theme es light. Cusps y aspectos secundarios del light palette bajan luminancia y suben alpha para no lavarse contra blanco. - Panel scroll (tahuantinsuyu-panel): body del control panel agrega `flex_grow + min_h(0) + overflow_y_scroll` para que cuando los controles no caben aparezca scroll vertical en lugar de cortarse. - Canvas zoom + pan (tahuantinsuyu-canvas): nuevo estado view_scale / view_pan_x / view_pan_y. Ctrl+wheel zoomea multiplicativo (clamp 0.5..3.0); wheel solo paneja. MMB drag para pan libre. Hotkey `0` resetea zoom+pan. Hit-tests del jog-dial y hover derivan ahora el `r_outer` del width actual del canvas, así se autoescalan con el zoom. - Panel dock lateral (shell.rs): nuevo `PanelDock { Bottom, Right, Left }` configurable desde 3 botones en el header (◧ ▭ ◨). Bottom mantiene el layout histórico (tree+canvas / panel); las variantes laterales colapsan los splitters anidados en uno solo horizontal de 3 columnas. El dock se persiste en `layout.panel_dock` y cada layout guarda sus flex en una key distinta para no pisarse. `load_split_flex_n` / `save_split_flex` generalizados a N hijos. Tests: 6 pasan (incluye nuevo roundtrip de PanelDock y N-flex). Co-Authored-By: Claude Opus 4.7 --- crates/apps/tahuantinsuyu/src/shell.rs | 453 ++++++++++++++---- .../tahuantinsuyu-canvas/src/lib.rs | 229 ++++++++- .../tahuantinsuyu-panel/src/lib.rs | 7 + .../tahuantinsuyu-theme/src/lib.rs | 26 +- .../ui_engine/widgets/splitter/src/lib.rs | 65 ++- 5 files changed, 633 insertions(+), 147 deletions(-) diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 34d5f5e..b20010e 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -24,8 +24,8 @@ use std::collections::HashMap; use gpui::{ - Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div, - prelude::*, px, + ClickEvent, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, + Window, div, prelude::*, px, }; use tahuantinsuyu_canvas::{ @@ -45,6 +45,35 @@ use yahweh_widget_container_core::ChildSlot; use yahweh_widget_splitter::{SplitContainer, SplitEvent}; use yahweh_widget_theme_switcher::theme_switcher; +/// Posición del panel de control dentro del shell. `Bottom` mantiene +/// el layout histórico (tree+canvas arriba, panel abajo); las variantes +/// laterales colapsan los splitters anidados en uno solo de 3 columnas. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelDock { + Bottom, + Right, + Left, +} + +impl PanelDock { + fn as_setting(&self) -> &'static str { + match self { + PanelDock::Bottom => "bottom", + PanelDock::Right => "right", + PanelDock::Left => "left", + } + } + + fn from_setting(s: &str) -> Option { + match s { + "bottom" => Some(PanelDock::Bottom), + "right" => Some(PanelDock::Right), + "left" => Some(PanelDock::Left), + _ => None, + } + } +} + /// Status del broker brahman tal como lo vimos en el último ping. /// Se refresca cada 30 segundos desde un background task. #[derive(Clone, Debug)] @@ -64,19 +93,24 @@ pub enum BrahmanStatus { pub struct Shell { store: Store, - /// El árbol vive como child de `outer_split` (vía AnyView clone), - /// pero retenemos el Entity acá para que las subscripciones - /// registradas en `new` sigan vivas — al droppear el último handle, - /// gpui cancela los suscriptores. - #[allow(dead_code)] + /// Los tres widgets viven como children de los splitters vía + /// AnyView clone; retenemos los Entity acá para que las + /// subscripciones sigan vivas y para poder rearmar el layout al + /// cambiar `dock` sin recrear los widgets. tree: Entity, canvas: Entity, panel: Entity, - /// Splitter vertical entre el main_row (arriba — tree + canvas) y - /// el panel de control (abajo). El splitter horizontal interno se - /// arma en `new` y queda referenciado vía `outer_split` (es uno de - /// sus children), sin necesidad de retenerlo aparte. + /// Splitter "exterior". En dock=Bottom es vertical con (main_split, + /// panel) como hijos; en dock=Right/Left es horizontal y agrupa + /// tree+canvas+panel en una sola tira. outer_split: Entity, + /// Splitter horizontal interno con (tree, canvas). Solo se usa + /// cuando dock=Bottom; en docks laterales queda vivo pero sin ser + /// hijo del árbol activo. + main_split: Entity, + /// Dock activo del panel — determina cómo se arman los splitters + /// y cuáles flex se persisten. + dock: PanelDock, /// Último estado conocido del broker brahman — refrescado cada /// 30s desde el background task. brahman_status: BrahmanStatus, @@ -125,90 +159,193 @@ impl Shell { }) .detach(); - // Splitter horizontal: tree + canvas. Defaults (1.0, 4.0) salvo - // que tengamos un flex persistido en `settings`. - let (main_left, main_right) = - load_split_flex(&store, "layout.main_split", 1.0, 4.0); + // Splitters vacíos — `apply_dock` los puebla según el layout + // activo. Horizontal/Vertical son defaults; cada apply ajusta la + // dirección antes de setear children. 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: main_left, - label: None, - view: gpui::AnyView::from(tree.clone()), - }, - ChildSlot { - id: NodeId::new("tts-canvas"), - flex: main_right, - label: None, - view: gpui::AnyView::from(canvas.clone()), - }, - ], - cx, - ); - }); + let outer_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Vertical, cx)); - // Splitter vertical: main arriba, panel abajo. Defaults (4.0, 1.0). - let (outer_top, outer_bottom) = - load_split_flex(&store, "layout.outer_split", 4.0, 1.0); - 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: outer_top, - label: None, - view: gpui::AnyView::from(main_split.clone()), - }, - ChildSlot { - id: NodeId::new("tts-panel"), - flex: outer_bottom, - label: None, - view: gpui::AnyView::from(panel.clone()), - }, - ], - cx, - ); - sc - }); - - // Persistir flex en `DragEnd`. Capturamos el store por valor - // (Store es Clone — comparte el Arc>). + // Persistir flex en `DragEnd`. La key del setting depende del + // dock activo, así no se pisan los flexes de un layout con los + // de otro al mudarse. Se lee dentro del closure para tomar el + // dock actualizado, no el capturado en `new`. let store_main = store.clone(); - cx.subscribe(&main_split, move |_, sc, ev: &SplitEvent, cx| { + cx.subscribe(&main_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| { if matches!(ev, SplitEvent::DragEnd) { - save_split_flex(&store_main, "layout.main_split", sc.read(cx)); + let key = split_key_main(this.dock); + save_split_flex(&store_main, key, sc.read(cx)); } }) .detach(); let store_outer = store.clone(); - cx.subscribe(&outer_split, move |_, sc, ev: &SplitEvent, cx| { + cx.subscribe(&outer_split, move |this: &mut Self, sc, ev: &SplitEvent, cx| { if matches!(ev, SplitEvent::DragEnd) { - save_split_flex(&store_outer, "layout.outer_split", sc.read(cx)); + let key = split_key_outer(this.dock); + save_split_flex(&store_outer, key, sc.read(cx)); } }) .detach(); - let shell = Self { + let dock = load_dock(&store).unwrap_or(PanelDock::Bottom); + + let mut shell = Self { store, tree, canvas, panel, outer_split, + main_split, + dock, brahman_status: BrahmanStatus::Pending, current_chart: None, current_offset_minutes: 0, module_configs: HashMap::new(), render_seq: 0, }; + shell.apply_dock(dock, cx); shell.refresh_chart_options(cx); shell.spawn_brahman_status_loop(cx); shell } + /// Arma el árbol de splitters según el dock pedido y persiste la + /// elección. Idempotente: llamar con el dock actual reconstruye los + /// children con flexes leídos del setting (útil tras `new`). + pub fn apply_dock(&mut self, dock: PanelDock, cx: &mut Context) { + self.dock = dock; + + let tree_view = gpui::AnyView::from(self.tree.clone()); + let canvas_view = gpui::AnyView::from(self.canvas.clone()); + let panel_view = gpui::AnyView::from(self.panel.clone()); + let main_view = gpui::AnyView::from(self.main_split.clone()); + + match dock { + PanelDock::Bottom => { + let flex_main = load_split_flex_n( + &self.store, + split_key_main(dock), + &[1.0, 4.0], + ); + let flex_outer = load_split_flex_n( + &self.store, + split_key_outer(dock), + &[4.0, 1.0], + ); + self.main_split.update(cx, |sc, cx| { + sc.set_direction(LayoutDirection::Horizontal, cx); + sc.set_children( + vec![ + ChildSlot { + id: NodeId::new("tts-tree"), + flex: flex_main[0], + label: None, + view: tree_view.clone(), + }, + ChildSlot { + id: NodeId::new("tts-canvas"), + flex: flex_main[1], + label: None, + view: canvas_view.clone(), + }, + ], + cx, + ); + }); + self.outer_split.update(cx, |sc, cx| { + sc.set_direction(LayoutDirection::Vertical, cx); + sc.set_children( + vec![ + ChildSlot { + id: NodeId::new("tts-main"), + flex: flex_outer[0], + label: None, + view: main_view, + }, + ChildSlot { + id: NodeId::new("tts-panel"), + flex: flex_outer[1], + label: None, + view: panel_view, + }, + ], + cx, + ); + }); + } + PanelDock::Right => { + let flex = load_split_flex_n( + &self.store, + split_key_outer(dock), + &[1.0, 4.0, 1.5], + ); + self.outer_split.update(cx, |sc, cx| { + sc.set_direction(LayoutDirection::Horizontal, cx); + sc.set_children( + vec![ + ChildSlot { + id: NodeId::new("tts-tree"), + flex: flex[0], + label: None, + view: tree_view, + }, + ChildSlot { + id: NodeId::new("tts-canvas"), + flex: flex[1], + label: None, + view: canvas_view, + }, + ChildSlot { + id: NodeId::new("tts-panel"), + flex: flex[2], + label: None, + view: panel_view, + }, + ], + cx, + ); + }); + } + PanelDock::Left => { + let flex = load_split_flex_n( + &self.store, + split_key_outer(dock), + &[1.5, 1.0, 4.0], + ); + self.outer_split.update(cx, |sc, cx| { + sc.set_direction(LayoutDirection::Horizontal, cx); + sc.set_children( + vec![ + ChildSlot { + id: NodeId::new("tts-panel"), + flex: flex[0], + label: None, + view: panel_view, + }, + ChildSlot { + id: NodeId::new("tts-tree"), + flex: flex[1], + label: None, + view: tree_view, + }, + ChildSlot { + id: NodeId::new("tts-canvas"), + flex: flex[2], + label: None, + view: canvas_view, + }, + ], + cx, + ); + }); + } + } + + if let Err(e) = self.store.set_setting("layout.panel_dock", dock.as_setting()) { + eprintln!("[shell] persist panel_dock: {}", e); + } + cx.notify(); + } + /// Loop que cada 30s pregunta al broker la lista de sessions /// activas y actualiza `brahman_status`. El cómputo bloqueante /// (list_sessions_blocking abre su propio tokio runtime) corre en @@ -918,35 +1055,122 @@ fn set_module_enabled( } } -/// Lee del `settings` el flex de un splitter (formato "left,right"). Si -/// no hay nada persistido o está corrupto, devuelve los defaults. -fn load_split_flex(store: &Store, key: &str, default_a: f32, default_b: f32) -> (f32, f32) { +/// Lee del `settings` el flex de un splitter en formato "f0,f1,..." y +/// lo devuelve como `Vec` con la misma longitud que `defaults`. +/// Si no hay nada persistido, faltan campos, o algún flex es ≤0, cae a +/// `defaults`. Validación estricta porque un flex 0 colapsa al panel. +fn load_split_flex_n(store: &Store, key: &str, defaults: &[f32]) -> Vec { let Ok(Some(raw)) = store.get_setting(key) else { - return (default_a, default_b); + return defaults.to_vec(); }; - let mut parts = raw.split(','); - let a = parts.next().and_then(|s| s.trim().parse::().ok()); - let b = parts.next().and_then(|s| s.trim().parse::().ok()); - match (a, b) { - (Some(a), Some(b)) if a > 0.0 && b > 0.0 => (a, b), - _ => (default_a, default_b), + let parsed: Vec = raw + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + if parsed.len() != defaults.len() || parsed.iter().any(|&f| f <= 0.0) { + return defaults.to_vec(); + } + parsed +} + +/// Persiste los flex actuales de un splitter — soporta N children. +fn save_split_flex(store: &Store, key: &str, sc: &SplitContainer) { + let children = sc.children(); + if children.is_empty() { + return; + } + let payload: String = children + .iter() + .map(|c| format!("{:.4}", c.flex)) + .collect::>() + .join(","); + if let Err(e) = store.set_setting(key, &payload) { + eprintln!("[shell] save_split_flex {}: {}", key, e); } } -/// Persiste los flex actuales de un splitter de 2 children. Si tiene -/// más children (en el futuro) sólo guarda los dos primeros — ajustar -/// el formato si se necesita más. -fn save_split_flex(store: &Store, key: &str, sc: &SplitContainer) { - let children = sc.children(); - let Some((first, rest)) = children.split_first() else { - return; - }; - let Some(second) = rest.first() else { - return; - }; - let payload = format!("{:.4},{:.4}", first.flex, second.flex); - if let Err(e) = store.set_setting(key, &payload) { - eprintln!("[shell] save_split_flex {}: {}", key, e); +/// Key del setting donde se persiste el splitter "outer" (el de mayor +/// nivel del árbol). En dock=Bottom guarda (main,panel); en docks +/// laterales guarda los flex de las 3 columnas — usamos keys distintas +/// para no pisar valores entre layouts. +fn split_key_outer(dock: PanelDock) -> &'static str { + match dock { + PanelDock::Bottom => "layout.outer_split", + PanelDock::Right => "layout.dock_right", + PanelDock::Left => "layout.dock_left", + } +} + +/// Key del setting del splitter horizontal interno. Solo se usa cuando +/// dock=Bottom (en docks laterales no hay main_split activo). +fn split_key_main(dock: PanelDock) -> &'static str { + match dock { + PanelDock::Bottom => "layout.main_split", + // En docks laterales el main_split está dormido — escribir acá + // no hace daño pero tampoco se usa al recargar. + PanelDock::Right => "layout.main_split_right", + PanelDock::Left => "layout.main_split_left", + } +} + +fn load_dock(store: &Store) -> Option { + let raw = store.get_setting("layout.panel_dock").ok().flatten()?; + PanelDock::from_setting(raw.trim()) +} + +impl Shell { + /// Tres botones compactos en el header — uno por dock disponible. + /// El dock activo se marca con `bg=accent`; los demás van planos. + /// Click llama a `apply_dock` que reorganiza splitters y persiste. + fn render_dock_switcher( + &self, + theme: &yahweh_theme::Theme, + cx: &mut Context, + ) -> impl IntoElement { + let mut row = div() + .id("tts-dock-switcher") + .flex() + .flex_row() + .gap(px(2.0)) + .px(px(2.0)) + .py(px(2.0)) + .rounded(px(6.0)) + .bg(theme.bg_panel_alt.clone()) + .border_1() + .border_color(theme.border); + + for (dock, glyph) in [ + (PanelDock::Left, "◧"), + (PanelDock::Bottom, "▭"), + (PanelDock::Right, "◨"), + ] { + let active = self.dock == dock; + let fg = if active { theme.fg_text } else { theme.fg_muted }; + let id: SharedString = SharedString::from(format!("tts-dock-{}", dock.as_setting())); + let mut btn = div() + .id(gpui::ElementId::from(id)) + .w(px(22.0)) + .h(px(20.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) + .text_size(px(12.0)) + .text_color(fg) + .hover(|s| s.bg(theme.bg_row_hover)) + .child(SharedString::from(glyph)) + .on_click(cx.listener(move |this, _: &ClickEvent, _w, cx| { + if this.dock != dock { + this.apply_dock(dock, cx); + } + })); + if active { + btn = btn.bg(theme.accent); + } + row = row.child(btn); + } + + row } } @@ -999,6 +1223,7 @@ impl Render for Shell { .child("estudio de astrología profesional"), ) .child(div().flex_grow()) + .child(self.render_dock_switcher(&theme, cx)) .child(brahman_badge) .child(theme_switcher(cx)); @@ -1171,23 +1396,55 @@ mod tests { /// El flex de los splitters persiste entre instancias de Shell que /// comparten la misma store (in-memory): primera shell escribe via - /// `save_split_flex`, segunda shell lee via `load_split_flex` al - /// boot. + /// `save_split_flex`, segunda shell lee via `load_split_flex_n` al + /// boot. Cubre 2 y 3 hijos (Bottom vs docks laterales). #[test] fn split_flex_round_trip_via_store() { let store = Store::in_memory().expect("store"); - // No hay nada persistido todavía: defaults. - assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 4.0)); + let defaults_2 = vec![1.0_f32, 4.0]; + let defaults_3 = vec![1.0_f32, 4.0, 1.5]; + + // Sin nada persistido → defaults. + assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2); store.set_setting("layout.x", "2.5,3.5").unwrap(); - assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (2.5, 3.5)); + assert_eq!( + load_split_flex_n(&store, "layout.x", &defaults_2), + vec![2.5_f32, 3.5] + ); + + store.set_setting("layout.x", "1.0,4.0,2.0").unwrap(); + assert_eq!( + load_split_flex_n(&store, "layout.x", &defaults_3), + vec![1.0_f32, 4.0, 2.0] + ); // Valor corrupto → defaults. store.set_setting("layout.x", "garbage").unwrap(); - assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 4.0)); + assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2); - // Valores ≤ 0 → defaults (los splitters tratan 0 como hidden). + // Cantidad incorrecta → defaults. + store.set_setting("layout.x", "2,3,4").unwrap(); + assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2); + + // Valor ≤0 → defaults. store.set_setting("layout.x", "0,5").unwrap(); - assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 4.0)); + assert_eq!(load_split_flex_n(&store, "layout.x", &defaults_2), defaults_2); + } + + /// PanelDock roundtrip via store. + #[test] + fn panel_dock_setting_roundtrip() { + assert_eq!(PanelDock::from_setting("bottom"), Some(PanelDock::Bottom)); + assert_eq!(PanelDock::from_setting("right"), Some(PanelDock::Right)); + assert_eq!(PanelDock::from_setting("left"), Some(PanelDock::Left)); + assert_eq!(PanelDock::from_setting("nope"), None); + + let store = Store::in_memory().expect("store"); + assert_eq!(load_dock(&store), None); + store + .set_setting("layout.panel_dock", PanelDock::Right.as_setting()) + .unwrap(); + assert_eq!(load_dock(&store), Some(PanelDock::Right)); } } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs index 1f5a720..68deb84 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs @@ -36,8 +36,8 @@ use std::f32::consts::PI; use gpui::{ Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, - PathBuilder, Pixels, Point, Render, SharedString, Styled, Window, canvas, div, hsla, - linear_color_stop, linear_gradient, point, prelude::*, px, + PathBuilder, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, SharedString, Styled, + Window, canvas, div, hsla, linear_color_stop, linear_gradient, point, prelude::*, px, }; use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel}; @@ -102,6 +102,15 @@ struct JogDragState { accumulated_delta_deg: f32, } +/// Drag activo de pan (MMB o LMB con Space). Captura el pan inicial al +/// hacer mousedown; el move agrega delta_pos a esos valores. +#[derive(Clone, Debug)] +struct PanDragState { + start_pos: Point, + pan_x_start: f32, + pan_y_start: f32, +} + #[derive(Clone, Debug)] pub struct CanvasState { pub mode: CanvasMode, @@ -111,14 +120,29 @@ pub struct CanvasState { /// Offset acumulado en minutos. Persiste entre drags hasta que el /// host lo resetee. pub time_offset_minutes: i64, + /// Factor de zoom multiplicativo aplicado al wheel. `1.0` = tamaño + /// nominal. Clampeado a [VIEW_SCALE_MIN, VIEW_SCALE_MAX]. + pub view_scale: f32, + /// Pan horizontal en px (positivo = desplaza el wheel a la derecha + /// desde el centro). Se aplica como margin shift sobre el centrado + /// natural del flex parent. + pub view_pan_x: f32, + /// Pan vertical en px (positivo = abajo). + pub view_pan_y: f32, /// Por-LayerKind: `true` = visible. Default = todo visible. pub layer_visibility: HashMap, /// Planeta hovered actualmente (para tooltip). `None` cuando el /// mouse no está sobre ningún cuerpo. pub hover: Option, drag_jog: Option, + drag_pan: Option, } +/// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre +/// 3.0 el wheel desborda incluso pantallas grandes. +pub const VIEW_SCALE_MIN: f32 = 0.5; +pub const VIEW_SCALE_MAX: f32 = 3.0; + /// Info del elemento bajo el cursor — usado por el render para mostrar /// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa, /// y líneas de aspectos. @@ -187,9 +211,13 @@ impl Default for CanvasState { mode: CanvasMode::default(), view_rotation_deg: 0.0, time_offset_minutes: 0, + view_scale: 1.0, + view_pan_x: 0.0, + view_pan_y: 0.0, layer_visibility: HashMap::new(), hover: None, drag_jog: None, + drag_pan: None, } } } @@ -263,6 +291,48 @@ impl AstrologyCanvas { cx.notify(); } + /// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation + /// ni time offset — esos son ortogonales y tienen su propio reset. + pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) { + if self.state.view_scale != 1.0 + || self.state.view_pan_x != 0.0 + || self.state.view_pan_y != 0.0 + { + self.state.view_scale = 1.0; + self.state.view_pan_x = 0.0; + self.state.view_pan_y = 0.0; + cx.notify(); + } + } + + /// Zoom multiplicativo. El nuevo scale es `current * factor`, clamp + /// al rango permitido. El zoom es centrado (no rastrea el cursor) — + /// para mover el foco después del zoom, el usuario paneja con MMB. + fn zoom_by(&mut self, factor: f32, cx: &mut Context<'_, Self>) { + let new_scale = + (self.state.view_scale * factor).clamp(VIEW_SCALE_MIN, VIEW_SCALE_MAX); + if (new_scale - self.state.view_scale).abs() < 1e-4 { + return; + } + // Mantener el centro del wheel anclado al centro de pantalla: + // como el pan está en coords de la pantalla y el zoom es desde + // el centro del wheel, el pan se escala proporcional al ratio. + let ratio = new_scale / self.state.view_scale; + self.state.view_pan_x *= ratio; + self.state.view_pan_y *= ratio; + self.state.view_scale = new_scale; + cx.notify(); + } + + fn pan_by(&mut self, dx: f32, dy: f32, cx: &mut Context<'_, Self>) { + if dx == 0.0 && dy == 0.0 { + return; + } + self.state.view_pan_x += dx; + self.state.view_pan_y += dy; + cx.notify(); + } + // ----- Internos: handlers de jog-dial ----- fn on_jog_down( @@ -277,7 +347,10 @@ impl AstrologyCanvas { let dx = mx - cx_px; let dy = my - cy_px; let dist = (dx * dx + dy * dy).sqrt(); - let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0; + // r_outer se deriva del width actual del canvas (que ya + // incorpora view_scale), no del WHEEL_SIZE constante. Sin esto, + // el jog-dial dejaría de funcionar al hacer zoom. + let r_outer = effective_r_outer(bounds); let radii = Radii::from_outer(r_outer); // Aro de captura un poco más generoso que el anillo del dial. if dist < radii.sign_inner * 0.95 || dist > radii.sign_outer * 1.10 { @@ -340,7 +413,7 @@ impl AstrologyCanvas { let my: f32 = position.y.into(); let ox: f32 = bounds.origin.x.into(); let oy: f32 = bounds.origin.y.into(); - let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0; + let r_outer = effective_r_outer(bounds); let radii = Radii::from_outer(r_outer); let asc = render.ascendant_deg; let rot = self.state.view_rotation_deg; @@ -494,6 +567,57 @@ impl AstrologyCanvas { } } + // ----- Internos: pan drag (MMB) ----- + + fn on_pan_down(&mut self, position: Point, _cx: &mut Context<'_, Self>) { + self.state.drag_pan = Some(PanDragState { + start_pos: position, + pan_x_start: self.state.view_pan_x, + pan_y_start: self.state.view_pan_y, + }); + } + + fn on_pan_move(&mut self, position: Point, cx: &mut Context<'_, Self>) { + let Some(pan) = self.state.drag_pan.as_ref() else { + return; + }; + let dx: f32 = (position.x - pan.start_pos.x).into(); + let dy: f32 = (position.y - pan.start_pos.y).into(); + self.state.view_pan_x = pan.pan_x_start + dx; + self.state.view_pan_y = pan.pan_y_start + dy; + cx.notify(); + } + + fn on_pan_up(&mut self, cx: &mut Context<'_, Self>) { + if self.state.drag_pan.take().is_some() { + cx.notify(); + } + } + + fn on_scroll( + &mut self, + event: &ScrollWheelEvent, + _w: &mut Window, + cx: &mut Context<'_, Self>, + ) { + let (dx_px, dy_px) = match event.delta { + ScrollDelta::Pixels(p) => (f32::from(p.x), f32::from(p.y)), + ScrollDelta::Lines(p) => (p.x * 16.0, p.y * 16.0), + }; + // Ctrl + wheel = zoom. wheel solo = pan (contenido sigue al + // dedo). El criterio de "modifier" usa el control flag estándar + // de gpui (en macOS sería cmd; aceptamos ambos como zoom). + let zoom_mod = event.modifiers.control || event.modifiers.platform; + if zoom_mod { + // Sensibilidad: 100px de scroll ≈ ±20% zoom. exp es suave y + // simétrico contra dy negativo (zoom out). + let factor = (dy_px * 0.002).exp(); + self.zoom_by(factor, cx); + } else { + self.pan_by(dx_px, dy_px, cx); + } + } + fn on_jog_up(&mut self, cx: &mut Context<'_, Self>) { let Some(jog) = self.state.drag_jog.take() else { return; @@ -528,6 +652,10 @@ impl AstrologyCanvas { self.reset_time_offset(cx); return; } + "0" => { + self.reset_view(cx); + return; + } "s" | "S" => { cx.emit(CanvasEvent::ExportSvgRequested); return; @@ -553,6 +681,17 @@ fn bounds_center(bounds: Bounds) -> (f32, f32) { (ox + bw / 2.0, oy + bh / 2.0) } +/// Radio del anillo exterior derivado del width *actual* del canvas +/// (que ya está escalado por view_scale). Mantiene la proporción del +/// margen contra `WHEEL_SIZE` original, así el hit-test del jog-dial y +/// las cusps se adapta automáticamente al zoom sin que cada caller +/// recalcule `view_scale`. +fn effective_r_outer(bounds: Bounds) -> f32 { + let bw: f32 = bounds.size.width.into(); + let scale = if WHEEL_SIZE > 0.0 { bw / WHEEL_SIZE } else { 1.0 }; + (bw - WHEEL_MARGIN * scale * 2.0) / 2.0 +} + // ===================================================================== // Render // ===================================================================== @@ -572,6 +711,9 @@ impl Render for AstrologyCanvas { render, self.state.view_rotation_deg, self.state.time_offset_minutes, + self.state.view_scale, + self.state.view_pan_x, + self.state.view_pan_y, &self.state.layer_visibility, self.state.hover.as_ref(), entity, @@ -590,12 +732,14 @@ impl Render for AstrologyCanvas { w.focus(&this.focus_handle); }), ) + .on_scroll_wheel(cx.listener(Self::on_scroll)) .size_full() .bg(theme.bg_panel.clone()) .flex() .flex_col() .items_center() .justify_center() + .overflow_hidden() .child(body) } } @@ -669,15 +813,24 @@ fn render_wheel( render: &RenderModel, view_rotation_deg: f32, time_offset_minutes: i64, + view_scale: f32, + view_pan_x: f32, + view_pan_y: f32, layer_visibility: &HashMap, hover: Option<&HoverInfo>, entity: gpui::Entity, ) -> gpui::Div { let asc = render.ascendant_deg; let rot_offset = view_rotation_deg; - let cx_center = WHEEL_SIZE / 2.0; - let cy_center = WHEEL_SIZE / 2.0; - let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0; + // Todo el wheel escala uniforme: el cuadro contenedor y los anillos + // crecen con view_scale, así que glifos, líneas y márgenes mantienen + // sus proporciones. cx/cy_center vive en coords locales del wheel, + // donde el wheel tiene tamaño `wheel_size` (no WHEEL_SIZE). + let wheel_size = WHEEL_SIZE * view_scale; + let wheel_margin = WHEEL_MARGIN * view_scale; + let cx_center = wheel_size / 2.0; + let cy_center = wheel_size / 2.0; + let r_outer = (wheel_size - wheel_margin * 2.0) / 2.0; let radii = Radii::from_outer(r_outer); let visible = layer_visibility.clone(); @@ -707,22 +860,36 @@ fn render_wheel( &visibility_for_paint, ); - // Handlers de mouse para el jog-dial — se registran cada - // frame contra el window; GPUI los reemplaza al re-renderear. + // Handlers de mouse — se registran cada frame contra el + // window; GPUI los reemplaza al re-renderear. Jog-dial (LMB + // sobre el anillo de signos) y pan (MMB en cualquier parte + // del canvas) coexisten porque consumen botones distintos. let entity_d = entity_for_canvas.clone(); window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| { - if ev.button != MouseButton::Left { - return; - } if !bounds.contains(&ev.position) { return; } - entity_d.update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx)); + match ev.button { + MouseButton::Left => { + entity_d + .update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx)); + } + MouseButton::Middle => { + entity_d.update(cx, |this, cx| this.on_pan_down(ev.position, cx)); + } + _ => {} + } }); let entity_m = entity_for_canvas.clone(); window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| { if ev.dragging() { - entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx)); + entity_m.update(cx, |this, cx| { + if this.state.drag_pan.is_some() { + this.on_pan_move(ev.position, cx); + } else { + this.on_jog_move(ev.position, bounds, cx); + } + }); } else if bounds.contains(&ev.position) { // Mouse hover sin drag: hit-test sobre los body // glyphs para el tooltip. @@ -737,28 +904,42 @@ fn render_wheel( }); let entity_u = entity_for_canvas.clone(); window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| { - entity_u.update(cx, |this, cx| this.on_jog_up(cx)); + entity_u.update(cx, |this, cx| { + this.on_pan_up(cx); + this.on_jog_up(cx); + }); }); }, ) .absolute() - .w(px(WHEEL_SIZE)) - .h(px(WHEEL_SIZE)); + .w(px(wheel_size)) + .h(px(wheel_size)); // Gradient sutil diagonal en el fondo del wheel — toque "místico - // velado": alpha muy baja, así no compite con la geometría pintada - // encima pero llena las zonas vacías (esquinas del cuadrado, gaps - // entre anillos) con un shimmer mineral. + // velado". En dark la alpha es muy baja (el fondo del panel ya es + // oscuro, no hace falta tinte fuerte). En light el panel es claro, + // así que necesitamos alphas mayores para que el gradient se vea + // como un fondo "papel teñido" y no se borre contra blanco. + let (a0, a1) = if theme.is_dark { + (0.06, 0.03) + } else { + (0.18, 0.10) + }; let wheel_bg = linear_gradient( 155.0, - linear_color_stop(with_alpha(palette.dial_ring, 0.06), 0.0), - linear_color_stop(with_alpha(palette.angle_highlight, 0.03), 1.0), + linear_color_stop(with_alpha(palette.dial_ring, a0), 0.0), + linear_color_stop(with_alpha(palette.angle_highlight, a1), 1.0), ); let mut wheel = div() .relative() - .w(px(WHEEL_SIZE)) - .h(px(WHEEL_SIZE)) + .w(px(wheel_size)) + .h(px(wheel_size)) + // El parent del canvas centra con flex; aplicamos el pan como + // margin shift desde ese centrado natural. Positivo = a la + // derecha / abajo; negativo desplaza al lado opuesto. + .ml(px(view_pan_x)) + .mt(px(view_pan_y)) .bg(wheel_bg) .rounded(px(12.0)) .child(canvas_element); diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs index 2d5445d..d2a127f 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-panel/src/lib.rs @@ -400,6 +400,13 @@ impl Render for ControlPanel { ); let mut body = div() + .id("tts-panel-body") + .flex_grow() + // `min_h(0)` libera al body de la altura intrínseca de su + // contenido — sin esto el flex_col padre lo expandiría hasta + // fit-content y el scroll nunca aparecería. + .min_h(px(0.0)) + .overflow_y_scroll() .flex() .flex_row() .flex_wrap() diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs index b32dd10..0086108 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs @@ -194,16 +194,24 @@ impl AstroPalette { south_node: hsla(35.0 / 360.0, 0.20, 0.30, 1.0), lilith: hsla(310.0 / 360.0, 0.50, 0.30, 1.0), - conjunction: hsla(45.0 / 360.0, 0.65, 0.40, 0.85), - sextile: hsla(195.0 / 360.0, 0.60, 0.38, 0.75), - square: hsla(8.0 / 360.0, 0.75, 0.40, 0.85), - trine: hsla(140.0 / 360.0, 0.55, 0.35, 0.80), - opposition: hsla(280.0 / 360.0, 0.55, 0.42, 0.85), - minor_aspect: hsla(220.0 / 360.0, 0.20, 0.45, 0.55), + // Aspectos en light: alpha alta y luminancia media-baja para + // que las líneas tengan presencia contra fondo claro. En dark + // las alphas pueden ser más bajas porque el contraste contra + // el fondo oscuro ya las hace destacar. + conjunction: hsla(45.0 / 360.0, 0.70, 0.38, 0.95), + sextile: hsla(195.0 / 360.0, 0.65, 0.36, 0.90), + square: hsla(8.0 / 360.0, 0.80, 0.38, 0.95), + trine: hsla(140.0 / 360.0, 0.60, 0.32, 0.92), + opposition: hsla(280.0 / 360.0, 0.60, 0.40, 0.95), + minor_aspect: hsla(220.0 / 360.0, 0.30, 0.38, 0.75), - dial_ring: hsla(40.0 / 360.0, 0.18, 0.32, 0.90), - house_cusp: hsla(40.0 / 360.0, 0.10, 0.45, 0.50), - angle_highlight: hsla(45.0 / 360.0, 0.85, 0.40, 1.0), + // dial_ring: luminancia baja (oscuro sobre blanco) para que + // el anillo de signos tenga peso. house_cusp: subimos alpha + // y bajamos luminancia para que las cúspides no se laven en + // un beige translúcido. + dial_ring: hsla(40.0 / 360.0, 0.20, 0.28, 0.95), + house_cusp: hsla(40.0 / 360.0, 0.15, 0.32, 0.80), + angle_highlight: hsla(38.0 / 360.0, 0.90, 0.38, 1.0), } } diff --git a/crates/modules/ui_engine/widgets/splitter/src/lib.rs b/crates/modules/ui_engine/widgets/splitter/src/lib.rs index ac1e48d..b2f4835 100644 --- a/crates/modules/ui_engine/widgets/splitter/src/lib.rs +++ b/crates/modules/ui_engine/widgets/splitter/src/lib.rs @@ -119,7 +119,7 @@ impl SplitContainer { // Restamos el espacio que ocupan los divisores — son fixed-size en el // eje principal, no participan del flex. El "espacio disponible // para flex" es lo que importa para convertir delta_px → delta_flex. - let dividers_total = px(DIVIDER_THICKNESS) * (self.children.len().saturating_sub(1) as f32); + let dividers_total = px(DIVIDER_HIT_ZONE) * (self.children.len().saturating_sub(1) as f32); let total_main = raw_main - dividers_total; if total_main <= px(0.0) { return; @@ -210,7 +210,12 @@ fn main_axis_pt(dir: LayoutDirection, p: Point) -> Pixels { // Render // ===================================================================== -const DIVIDER_THICKNESS: f32 = 4.0; +/// Espesor visible de la franja del divisor (la barrita coloreada). +const DIVIDER_VISUAL: f32 = 4.0; +/// Espesor total de la zona interactiva: cursor + handlers de mouse. Más +/// generosa que el visual para no pelearse con el usuario al apuntar a +/// una banda de 4px. El visual queda centrado dentro del hit zone. +const DIVIDER_HIT_ZONE: f32 = 12.0; impl Render for SplitContainer { fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -260,13 +265,21 @@ impl Render for SplitContainer { item.style().flex_grow = Some(weight); item.style().flex_shrink = Some(1.0); - // CRUCIAL: el default de flexbox es `min-width: auto` (= min - // content size). Si no lo aplastamos a 0, taffy clamp-ea al - // tamaño mínimo del contenido (un TreeView con label largo, un - // uniform_list, etc.) y el divisor no puede pasar de ese punto - // — el cursor avanza pero el divisor se queda. Forzando min=0 - // y overflow:hidden en el wrapper, el child puede shrink-arse a - // donde sea y el contenido se recorta. + // CRUCIAL: flex-basis = 0 (no `auto`). El default `auto` toma + // el min-content de cada hijo como punto de partida; cuando un + // hijo tiene contenido grande (canvas con WHEEL_SIZE fijo, un + // panel con muchos controles en flex_wrap, etc.) la suma de + // bases excede el contenedor y flexbox abandona el reparto + // por flex-grow para usar shrink proporcional a la basis — + // resultado: el ratio 1:4 que pide el host se ignora y el + // hijo más liviano (p. ej. el tree) se aplasta a 0px. Con + // basis=0 todo el espacio es "free space" y el ratio se + // respeta sin importar el contenido. + item.style().flex_basis = Some(Length::Definite(px(0.0).into())); + + // Floor de shrink: con basis=0 esto rara vez importa, pero lo + // dejamos por defensa contra contenidos que fuercen min-size + // intrínseco (uniform_list mide su primera row, etc.). item.style().min_size.width = Some(Length::Definite(px(0.0).into())); item.style().min_size.height = Some(Length::Definite(px(0.0).into())); @@ -285,27 +298,46 @@ impl Render for SplitContainer { let divider_idx = i; let entity_for_canvas = entity.clone(); - let mut divider = div(); - let divider_bg = if self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx) - { + let is_active = self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx); + let visual_bg = if is_active { theme.accent_strong } else { theme.border_strong }; - divider = divider.bg(divider_bg).hover(|s| s.bg(theme.accent)); + // Visual: la franja fina coloreada que el usuario ve. + let visual = match direction { + LayoutDirection::Horizontal => div() + .w(px(DIVIDER_VISUAL)) + .h_full() + .bg(visual_bg), + _ => div() + .w_full() + .h(px(DIVIDER_VISUAL)) + .bg(visual_bg), + }; + + // Hit zone: wrapper transparente más ancho que captura + // cursor y handlers de mouse. Centra el visual con flex. + // `relative` para que el canvas hijo (absolute) se ancle + // al wrapper y reporte sus bounds correctos. + let mut divider = div().relative().flex().items_center().justify_center(); divider = match direction { LayoutDirection::Horizontal => divider - .w(px(DIVIDER_THICKNESS)) + .w(px(DIVIDER_HIT_ZONE)) .h_full() .cursor_ew_resize(), _ => divider .w_full() - .h(px(DIVIDER_THICKNESS)) + .h(px(DIVIDER_HIT_ZONE)) .cursor_ns_resize(), }; + divider = divider.child(visual); - // Canvas con handlers de drag a nivel de window. + // Canvas con handlers de drag a nivel de window — su + // bounds = bounds del wrapper (hit zone completo), así + // que el `canvas_bounds.contains` acepta clicks en todo + // el ancho del hit zone, no solo sobre el visual. let divider = divider.child( canvas( |_, _, _| (), @@ -350,6 +382,7 @@ impl Render for SplitContainer { }); }, ) + .absolute() .size_full(), );