feat(tahuantinsuyu): UX pass — splitter, light wheel, scroll, zoom/pan, dock lateral

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 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 15:10:16 +00:00
parent d2b6b8b12e
commit e09207b152
5 changed files with 633 additions and 147 deletions
@@ -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<Pixels>,
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<LayerKind, bool>,
/// Planeta hovered actualmente (para tooltip). `None` cuando el
/// mouse no está sobre ningún cuerpo.
pub hover: Option<HoverInfo>,
drag_jog: Option<JogDragState>,
drag_pan: Option<PanDragState>,
}
/// 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<Pixels>, _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<Pixels>, 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<Pixels>) -> (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<Pixels>) -> 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<LayerKind, bool>,
hover: Option<&HoverInfo>,
entity: gpui::Entity<AstrologyCanvas>,
) -> 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);
@@ -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()
@@ -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),
}
}