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
+355 -98
View File
@@ -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<Self> {
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<TahuantinsuyuTree>,
canvas: Entity<AstrologyCanvas>,
panel: Entity<ControlPanel>,
/// 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<SplitContainer>,
/// 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<SplitContainer>,
/// 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<Mutex<Connection>>).
// 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>) {
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<f32>` 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<f32> {
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::<f32>().ok());
let b = parts.next().and_then(|s| s.trim().parse::<f32>().ok());
match (a, b) {
(Some(a), Some(b)) if a > 0.0 && b > 0.0 => (a, b),
_ => (default_a, default_b),
let parsed: Vec<f32> = raw
.split(',')
.filter_map(|s| s.trim().parse::<f32>().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::<Vec<_>>()
.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<PanelDock> {
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<Self>,
) -> 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));
}
}