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
@@ -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>) -> 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<Self>) -> 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(),
);