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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user