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:
@@ -24,8 +24,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Window, div,
|
ClickEvent, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
prelude::*, px,
|
Window, div, prelude::*, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
use tahuantinsuyu_canvas::{
|
use tahuantinsuyu_canvas::{
|
||||||
@@ -45,6 +45,35 @@ use yahweh_widget_container_core::ChildSlot;
|
|||||||
use yahweh_widget_splitter::{SplitContainer, SplitEvent};
|
use yahweh_widget_splitter::{SplitContainer, SplitEvent};
|
||||||
use yahweh_widget_theme_switcher::theme_switcher;
|
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.
|
/// Status del broker brahman tal como lo vimos en el último ping.
|
||||||
/// Se refresca cada 30 segundos desde un background task.
|
/// Se refresca cada 30 segundos desde un background task.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -64,19 +93,24 @@ pub enum BrahmanStatus {
|
|||||||
|
|
||||||
pub struct Shell {
|
pub struct Shell {
|
||||||
store: Store,
|
store: Store,
|
||||||
/// El árbol vive como child de `outer_split` (vía AnyView clone),
|
/// Los tres widgets viven como children de los splitters vía
|
||||||
/// pero retenemos el Entity acá para que las subscripciones
|
/// AnyView clone; retenemos los Entity acá para que las
|
||||||
/// registradas en `new` sigan vivas — al droppear el último handle,
|
/// subscripciones sigan vivas y para poder rearmar el layout al
|
||||||
/// gpui cancela los suscriptores.
|
/// cambiar `dock` sin recrear los widgets.
|
||||||
#[allow(dead_code)]
|
|
||||||
tree: Entity<TahuantinsuyuTree>,
|
tree: Entity<TahuantinsuyuTree>,
|
||||||
canvas: Entity<AstrologyCanvas>,
|
canvas: Entity<AstrologyCanvas>,
|
||||||
panel: Entity<ControlPanel>,
|
panel: Entity<ControlPanel>,
|
||||||
/// Splitter vertical entre el main_row (arriba — tree + canvas) y
|
/// Splitter "exterior". En dock=Bottom es vertical con (main_split,
|
||||||
/// el panel de control (abajo). El splitter horizontal interno se
|
/// panel) como hijos; en dock=Right/Left es horizontal y agrupa
|
||||||
/// arma en `new` y queda referenciado vía `outer_split` (es uno de
|
/// tree+canvas+panel en una sola tira.
|
||||||
/// sus children), sin necesidad de retenerlo aparte.
|
|
||||||
outer_split: Entity<SplitContainer>,
|
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
|
/// Último estado conocido del broker brahman — refrescado cada
|
||||||
/// 30s desde el background task.
|
/// 30s desde el background task.
|
||||||
brahman_status: BrahmanStatus,
|
brahman_status: BrahmanStatus,
|
||||||
@@ -125,90 +159,193 @@ impl Shell {
|
|||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
// Splitter horizontal: tree + canvas. Defaults (1.0, 4.0) salvo
|
// Splitters vacíos — `apply_dock` los puebla según el layout
|
||||||
// que tengamos un flex persistido en `settings`.
|
// activo. Horizontal/Vertical son defaults; cada apply ajusta la
|
||||||
let (main_left, main_right) =
|
// dirección antes de setear children.
|
||||||
load_split_flex(&store, "layout.main_split", 1.0, 4.0);
|
|
||||||
let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx));
|
let main_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Horizontal, cx));
|
||||||
main_split.update(cx, |sc, cx| {
|
let outer_split = cx.new(|cx| SplitContainer::new(LayoutDirection::Vertical, 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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Splitter vertical: main arriba, panel abajo. Defaults (4.0, 1.0).
|
// Persistir flex en `DragEnd`. La key del setting depende del
|
||||||
let (outer_top, outer_bottom) =
|
// dock activo, así no se pisan los flexes de un layout con los
|
||||||
load_split_flex(&store, "layout.outer_split", 4.0, 1.0);
|
// de otro al mudarse. Se lee dentro del closure para tomar el
|
||||||
let outer_split = cx.new(|cx| {
|
// dock actualizado, no el capturado en `new`.
|
||||||
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>>).
|
|
||||||
let store_main = store.clone();
|
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) {
|
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();
|
.detach();
|
||||||
let store_outer = store.clone();
|
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) {
|
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();
|
.detach();
|
||||||
|
|
||||||
let shell = Self {
|
let dock = load_dock(&store).unwrap_or(PanelDock::Bottom);
|
||||||
|
|
||||||
|
let mut shell = Self {
|
||||||
store,
|
store,
|
||||||
tree,
|
tree,
|
||||||
canvas,
|
canvas,
|
||||||
panel,
|
panel,
|
||||||
outer_split,
|
outer_split,
|
||||||
|
main_split,
|
||||||
|
dock,
|
||||||
brahman_status: BrahmanStatus::Pending,
|
brahman_status: BrahmanStatus::Pending,
|
||||||
current_chart: None,
|
current_chart: None,
|
||||||
current_offset_minutes: 0,
|
current_offset_minutes: 0,
|
||||||
module_configs: HashMap::new(),
|
module_configs: HashMap::new(),
|
||||||
render_seq: 0,
|
render_seq: 0,
|
||||||
};
|
};
|
||||||
|
shell.apply_dock(dock, cx);
|
||||||
shell.refresh_chart_options(cx);
|
shell.refresh_chart_options(cx);
|
||||||
shell.spawn_brahman_status_loop(cx);
|
shell.spawn_brahman_status_loop(cx);
|
||||||
shell
|
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
|
/// Loop que cada 30s pregunta al broker la lista de sessions
|
||||||
/// activas y actualiza `brahman_status`. El cómputo bloqueante
|
/// activas y actualiza `brahman_status`. El cómputo bloqueante
|
||||||
/// (list_sessions_blocking abre su propio tokio runtime) corre en
|
/// (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
|
/// Lee del `settings` el flex de un splitter en formato "f0,f1,..." y
|
||||||
/// no hay nada persistido o está corrupto, devuelve los defaults.
|
/// lo devuelve como `Vec<f32>` con la misma longitud que `defaults`.
|
||||||
fn load_split_flex(store: &Store, key: &str, default_a: f32, default_b: f32) -> (f32, f32) {
|
/// 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 {
|
let Ok(Some(raw)) = store.get_setting(key) else {
|
||||||
return (default_a, default_b);
|
return defaults.to_vec();
|
||||||
};
|
};
|
||||||
let mut parts = raw.split(',');
|
let parsed: Vec<f32> = raw
|
||||||
let a = parts.next().and_then(|s| s.trim().parse::<f32>().ok());
|
.split(',')
|
||||||
let b = parts.next().and_then(|s| s.trim().parse::<f32>().ok());
|
.filter_map(|s| s.trim().parse::<f32>().ok())
|
||||||
match (a, b) {
|
.collect();
|
||||||
(Some(a), Some(b)) if a > 0.0 && b > 0.0 => (a, b),
|
if parsed.len() != defaults.len() || parsed.iter().any(|&f| f <= 0.0) {
|
||||||
_ => (default_a, default_b),
|
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
|
/// Key del setting donde se persiste el splitter "outer" (el de mayor
|
||||||
/// más children (en el futuro) sólo guarda los dos primeros — ajustar
|
/// nivel del árbol). En dock=Bottom guarda (main,panel); en docks
|
||||||
/// el formato si se necesita más.
|
/// laterales guarda los flex de las 3 columnas — usamos keys distintas
|
||||||
fn save_split_flex(store: &Store, key: &str, sc: &SplitContainer) {
|
/// para no pisar valores entre layouts.
|
||||||
let children = sc.children();
|
fn split_key_outer(dock: PanelDock) -> &'static str {
|
||||||
let Some((first, rest)) = children.split_first() else {
|
match dock {
|
||||||
return;
|
PanelDock::Bottom => "layout.outer_split",
|
||||||
};
|
PanelDock::Right => "layout.dock_right",
|
||||||
let Some(second) = rest.first() else {
|
PanelDock::Left => "layout.dock_left",
|
||||||
return;
|
}
|
||||||
};
|
}
|
||||||
let payload = format!("{:.4},{:.4}", first.flex, second.flex);
|
|
||||||
if let Err(e) = store.set_setting(key, &payload) {
|
/// Key del setting del splitter horizontal interno. Solo se usa cuando
|
||||||
eprintln!("[shell] save_split_flex {}: {}", key, e);
|
/// 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("estudio de astrología profesional"),
|
||||||
)
|
)
|
||||||
.child(div().flex_grow())
|
.child(div().flex_grow())
|
||||||
|
.child(self.render_dock_switcher(&theme, cx))
|
||||||
.child(brahman_badge)
|
.child(brahman_badge)
|
||||||
.child(theme_switcher(cx));
|
.child(theme_switcher(cx));
|
||||||
|
|
||||||
@@ -1171,23 +1396,55 @@ mod tests {
|
|||||||
|
|
||||||
/// El flex de los splitters persiste entre instancias de Shell que
|
/// El flex de los splitters persiste entre instancias de Shell que
|
||||||
/// comparten la misma store (in-memory): primera shell escribe via
|
/// comparten la misma store (in-memory): primera shell escribe via
|
||||||
/// `save_split_flex`, segunda shell lee via `load_split_flex` al
|
/// `save_split_flex`, segunda shell lee via `load_split_flex_n` al
|
||||||
/// boot.
|
/// boot. Cubre 2 y 3 hijos (Bottom vs docks laterales).
|
||||||
#[test]
|
#[test]
|
||||||
fn split_flex_round_trip_via_store() {
|
fn split_flex_round_trip_via_store() {
|
||||||
let store = Store::in_memory().expect("store");
|
let store = Store::in_memory().expect("store");
|
||||||
// No hay nada persistido todavía: defaults.
|
let defaults_2 = vec![1.0_f32, 4.0];
|
||||||
assert_eq!(load_split_flex(&store, "layout.x", 1.0, 4.0), (1.0, 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();
|
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.
|
// Valor corrupto → defaults.
|
||||||
store.set_setting("layout.x", "garbage").unwrap();
|
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();
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ use std::f32::consts::PI;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
|
Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement,
|
||||||
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
|
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
|
||||||
PathBuilder, Pixels, Point, Render, SharedString, Styled, Window, canvas, div, hsla,
|
PathBuilder, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, SharedString, Styled,
|
||||||
linear_color_stop, linear_gradient, point, prelude::*, px,
|
Window, canvas, div, hsla, linear_color_stop, linear_gradient, point, prelude::*, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel};
|
use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel};
|
||||||
@@ -102,6 +102,15 @@ struct JogDragState {
|
|||||||
accumulated_delta_deg: f32,
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct CanvasState {
|
pub struct CanvasState {
|
||||||
pub mode: CanvasMode,
|
pub mode: CanvasMode,
|
||||||
@@ -111,14 +120,29 @@ pub struct CanvasState {
|
|||||||
/// Offset acumulado en minutos. Persiste entre drags hasta que el
|
/// Offset acumulado en minutos. Persiste entre drags hasta que el
|
||||||
/// host lo resetee.
|
/// host lo resetee.
|
||||||
pub time_offset_minutes: i64,
|
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.
|
/// Por-LayerKind: `true` = visible. Default = todo visible.
|
||||||
pub layer_visibility: HashMap<LayerKind, bool>,
|
pub layer_visibility: HashMap<LayerKind, bool>,
|
||||||
/// Planeta hovered actualmente (para tooltip). `None` cuando el
|
/// Planeta hovered actualmente (para tooltip). `None` cuando el
|
||||||
/// mouse no está sobre ningún cuerpo.
|
/// mouse no está sobre ningún cuerpo.
|
||||||
pub hover: Option<HoverInfo>,
|
pub hover: Option<HoverInfo>,
|
||||||
drag_jog: Option<JogDragState>,
|
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
|
/// Info del elemento bajo el cursor — usado por el render para mostrar
|
||||||
/// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
|
/// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
|
||||||
/// y líneas de aspectos.
|
/// y líneas de aspectos.
|
||||||
@@ -187,9 +211,13 @@ impl Default for CanvasState {
|
|||||||
mode: CanvasMode::default(),
|
mode: CanvasMode::default(),
|
||||||
view_rotation_deg: 0.0,
|
view_rotation_deg: 0.0,
|
||||||
time_offset_minutes: 0,
|
time_offset_minutes: 0,
|
||||||
|
view_scale: 1.0,
|
||||||
|
view_pan_x: 0.0,
|
||||||
|
view_pan_y: 0.0,
|
||||||
layer_visibility: HashMap::new(),
|
layer_visibility: HashMap::new(),
|
||||||
hover: None,
|
hover: None,
|
||||||
drag_jog: None,
|
drag_jog: None,
|
||||||
|
drag_pan: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +291,48 @@ impl AstrologyCanvas {
|
|||||||
cx.notify();
|
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 -----
|
// ----- Internos: handlers de jog-dial -----
|
||||||
|
|
||||||
fn on_jog_down(
|
fn on_jog_down(
|
||||||
@@ -277,7 +347,10 @@ impl AstrologyCanvas {
|
|||||||
let dx = mx - cx_px;
|
let dx = mx - cx_px;
|
||||||
let dy = my - cy_px;
|
let dy = my - cy_px;
|
||||||
let dist = (dx * dx + dy * dy).sqrt();
|
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);
|
let radii = Radii::from_outer(r_outer);
|
||||||
// Aro de captura un poco más generoso que el anillo del dial.
|
// 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 {
|
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 my: f32 = position.y.into();
|
||||||
let ox: f32 = bounds.origin.x.into();
|
let ox: f32 = bounds.origin.x.into();
|
||||||
let oy: f32 = bounds.origin.y.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 radii = Radii::from_outer(r_outer);
|
||||||
let asc = render.ascendant_deg;
|
let asc = render.ascendant_deg;
|
||||||
let rot = self.state.view_rotation_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>) {
|
fn on_jog_up(&mut self, cx: &mut Context<'_, Self>) {
|
||||||
let Some(jog) = self.state.drag_jog.take() else {
|
let Some(jog) = self.state.drag_jog.take() else {
|
||||||
return;
|
return;
|
||||||
@@ -528,6 +652,10 @@ impl AstrologyCanvas {
|
|||||||
self.reset_time_offset(cx);
|
self.reset_time_offset(cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
"0" => {
|
||||||
|
self.reset_view(cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
"s" | "S" => {
|
"s" | "S" => {
|
||||||
cx.emit(CanvasEvent::ExportSvgRequested);
|
cx.emit(CanvasEvent::ExportSvgRequested);
|
||||||
return;
|
return;
|
||||||
@@ -553,6 +681,17 @@ fn bounds_center(bounds: Bounds<Pixels>) -> (f32, f32) {
|
|||||||
(ox + bw / 2.0, oy + bh / 2.0)
|
(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
|
// Render
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -572,6 +711,9 @@ impl Render for AstrologyCanvas {
|
|||||||
render,
|
render,
|
||||||
self.state.view_rotation_deg,
|
self.state.view_rotation_deg,
|
||||||
self.state.time_offset_minutes,
|
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.layer_visibility,
|
||||||
self.state.hover.as_ref(),
|
self.state.hover.as_ref(),
|
||||||
entity,
|
entity,
|
||||||
@@ -590,12 +732,14 @@ impl Render for AstrologyCanvas {
|
|||||||
w.focus(&this.focus_handle);
|
w.focus(&this.focus_handle);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.on_scroll_wheel(cx.listener(Self::on_scroll))
|
||||||
.size_full()
|
.size_full()
|
||||||
.bg(theme.bg_panel.clone())
|
.bg(theme.bg_panel.clone())
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
|
.overflow_hidden()
|
||||||
.child(body)
|
.child(body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -669,15 +813,24 @@ fn render_wheel(
|
|||||||
render: &RenderModel,
|
render: &RenderModel,
|
||||||
view_rotation_deg: f32,
|
view_rotation_deg: f32,
|
||||||
time_offset_minutes: i64,
|
time_offset_minutes: i64,
|
||||||
|
view_scale: f32,
|
||||||
|
view_pan_x: f32,
|
||||||
|
view_pan_y: f32,
|
||||||
layer_visibility: &HashMap<LayerKind, bool>,
|
layer_visibility: &HashMap<LayerKind, bool>,
|
||||||
hover: Option<&HoverInfo>,
|
hover: Option<&HoverInfo>,
|
||||||
entity: gpui::Entity<AstrologyCanvas>,
|
entity: gpui::Entity<AstrologyCanvas>,
|
||||||
) -> gpui::Div {
|
) -> gpui::Div {
|
||||||
let asc = render.ascendant_deg;
|
let asc = render.ascendant_deg;
|
||||||
let rot_offset = view_rotation_deg;
|
let rot_offset = view_rotation_deg;
|
||||||
let cx_center = WHEEL_SIZE / 2.0;
|
// Todo el wheel escala uniforme: el cuadro contenedor y los anillos
|
||||||
let cy_center = WHEEL_SIZE / 2.0;
|
// crecen con view_scale, así que glifos, líneas y márgenes mantienen
|
||||||
let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
|
// 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 radii = Radii::from_outer(r_outer);
|
||||||
|
|
||||||
let visible = layer_visibility.clone();
|
let visible = layer_visibility.clone();
|
||||||
@@ -707,22 +860,36 @@ fn render_wheel(
|
|||||||
&visibility_for_paint,
|
&visibility_for_paint,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handlers de mouse para el jog-dial — se registran cada
|
// Handlers de mouse — se registran cada frame contra el
|
||||||
// frame contra el window; GPUI los reemplaza al re-renderear.
|
// 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();
|
let entity_d = entity_for_canvas.clone();
|
||||||
window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
|
window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
|
||||||
if ev.button != MouseButton::Left {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if !bounds.contains(&ev.position) {
|
if !bounds.contains(&ev.position) {
|
||||||
return;
|
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();
|
let entity_m = entity_for_canvas.clone();
|
||||||
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
|
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
|
||||||
if ev.dragging() {
|
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) {
|
} else if bounds.contains(&ev.position) {
|
||||||
// Mouse hover sin drag: hit-test sobre los body
|
// Mouse hover sin drag: hit-test sobre los body
|
||||||
// glyphs para el tooltip.
|
// glyphs para el tooltip.
|
||||||
@@ -737,28 +904,42 @@ fn render_wheel(
|
|||||||
});
|
});
|
||||||
let entity_u = entity_for_canvas.clone();
|
let entity_u = entity_for_canvas.clone();
|
||||||
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
|
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()
|
.absolute()
|
||||||
.w(px(WHEEL_SIZE))
|
.w(px(wheel_size))
|
||||||
.h(px(WHEEL_SIZE));
|
.h(px(wheel_size));
|
||||||
|
|
||||||
// Gradient sutil diagonal en el fondo del wheel — toque "místico
|
// Gradient sutil diagonal en el fondo del wheel — toque "místico
|
||||||
// velado": alpha muy baja, así no compite con la geometría pintada
|
// velado". En dark la alpha es muy baja (el fondo del panel ya es
|
||||||
// encima pero llena las zonas vacías (esquinas del cuadrado, gaps
|
// oscuro, no hace falta tinte fuerte). En light el panel es claro,
|
||||||
// entre anillos) con un shimmer mineral.
|
// 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(
|
let wheel_bg = linear_gradient(
|
||||||
155.0,
|
155.0,
|
||||||
linear_color_stop(with_alpha(palette.dial_ring, 0.06), 0.0),
|
linear_color_stop(with_alpha(palette.dial_ring, a0), 0.0),
|
||||||
linear_color_stop(with_alpha(palette.angle_highlight, 0.03), 1.0),
|
linear_color_stop(with_alpha(palette.angle_highlight, a1), 1.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut wheel = div()
|
let mut wheel = div()
|
||||||
.relative()
|
.relative()
|
||||||
.w(px(WHEEL_SIZE))
|
.w(px(wheel_size))
|
||||||
.h(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)
|
.bg(wheel_bg)
|
||||||
.rounded(px(12.0))
|
.rounded(px(12.0))
|
||||||
.child(canvas_element);
|
.child(canvas_element);
|
||||||
|
|||||||
@@ -400,6 +400,13 @@ impl Render for ControlPanel {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut body = div()
|
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()
|
||||||
.flex_row()
|
.flex_row()
|
||||||
.flex_wrap()
|
.flex_wrap()
|
||||||
|
|||||||
@@ -194,16 +194,24 @@ impl AstroPalette {
|
|||||||
south_node: hsla(35.0 / 360.0, 0.20, 0.30, 1.0),
|
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),
|
lilith: hsla(310.0 / 360.0, 0.50, 0.30, 1.0),
|
||||||
|
|
||||||
conjunction: hsla(45.0 / 360.0, 0.65, 0.40, 0.85),
|
// Aspectos en light: alpha alta y luminancia media-baja para
|
||||||
sextile: hsla(195.0 / 360.0, 0.60, 0.38, 0.75),
|
// que las líneas tengan presencia contra fondo claro. En dark
|
||||||
square: hsla(8.0 / 360.0, 0.75, 0.40, 0.85),
|
// las alphas pueden ser más bajas porque el contraste contra
|
||||||
trine: hsla(140.0 / 360.0, 0.55, 0.35, 0.80),
|
// el fondo oscuro ya las hace destacar.
|
||||||
opposition: hsla(280.0 / 360.0, 0.55, 0.42, 0.85),
|
conjunction: hsla(45.0 / 360.0, 0.70, 0.38, 0.95),
|
||||||
minor_aspect: hsla(220.0 / 360.0, 0.20, 0.45, 0.55),
|
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),
|
// dial_ring: luminancia baja (oscuro sobre blanco) para que
|
||||||
house_cusp: hsla(40.0 / 360.0, 0.10, 0.45, 0.50),
|
// el anillo de signos tenga peso. house_cusp: subimos alpha
|
||||||
angle_highlight: hsla(45.0 / 360.0, 0.85, 0.40, 1.0),
|
// 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ impl SplitContainer {
|
|||||||
// Restamos el espacio que ocupan los divisores — son fixed-size en el
|
// Restamos el espacio que ocupan los divisores — son fixed-size en el
|
||||||
// eje principal, no participan del flex. El "espacio disponible
|
// eje principal, no participan del flex. El "espacio disponible
|
||||||
// para flex" es lo que importa para convertir delta_px → delta_flex.
|
// 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;
|
let total_main = raw_main - dividers_total;
|
||||||
if total_main <= px(0.0) {
|
if total_main <= px(0.0) {
|
||||||
return;
|
return;
|
||||||
@@ -210,7 +210,12 @@ fn main_axis_pt(dir: LayoutDirection, p: Point<Pixels>) -> Pixels {
|
|||||||
// Render
|
// 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 {
|
impl Render for SplitContainer {
|
||||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
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_grow = Some(weight);
|
||||||
item.style().flex_shrink = Some(1.0);
|
item.style().flex_shrink = Some(1.0);
|
||||||
|
|
||||||
// CRUCIAL: el default de flexbox es `min-width: auto` (= min
|
// CRUCIAL: flex-basis = 0 (no `auto`). El default `auto` toma
|
||||||
// content size). Si no lo aplastamos a 0, taffy clamp-ea al
|
// el min-content de cada hijo como punto de partida; cuando un
|
||||||
// tamaño mínimo del contenido (un TreeView con label largo, un
|
// hijo tiene contenido grande (canvas con WHEEL_SIZE fijo, un
|
||||||
// uniform_list, etc.) y el divisor no puede pasar de ese punto
|
// panel con muchos controles en flex_wrap, etc.) la suma de
|
||||||
// — el cursor avanza pero el divisor se queda. Forzando min=0
|
// bases excede el contenedor y flexbox abandona el reparto
|
||||||
// y overflow:hidden en el wrapper, el child puede shrink-arse a
|
// por flex-grow para usar shrink proporcional a la basis —
|
||||||
// donde sea y el contenido se recorta.
|
// 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.width = Some(Length::Definite(px(0.0).into()));
|
||||||
item.style().min_size.height = 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 divider_idx = i;
|
||||||
let entity_for_canvas = entity.clone();
|
let entity_for_canvas = entity.clone();
|
||||||
|
|
||||||
let mut divider = div();
|
let is_active = self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx);
|
||||||
let divider_bg = if self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx)
|
let visual_bg = if is_active {
|
||||||
{
|
|
||||||
theme.accent_strong
|
theme.accent_strong
|
||||||
} else {
|
} else {
|
||||||
theme.border_strong
|
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 {
|
divider = match direction {
|
||||||
LayoutDirection::Horizontal => divider
|
LayoutDirection::Horizontal => divider
|
||||||
.w(px(DIVIDER_THICKNESS))
|
.w(px(DIVIDER_HIT_ZONE))
|
||||||
.h_full()
|
.h_full()
|
||||||
.cursor_ew_resize(),
|
.cursor_ew_resize(),
|
||||||
_ => divider
|
_ => divider
|
||||||
.w_full()
|
.w_full()
|
||||||
.h(px(DIVIDER_THICKNESS))
|
.h(px(DIVIDER_HIT_ZONE))
|
||||||
.cursor_ns_resize(),
|
.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(
|
let divider = divider.child(
|
||||||
canvas(
|
canvas(
|
||||||
|_, _, _| (),
|
|_, _, _| (),
|
||||||
@@ -350,6 +382,7 @@ impl Render for SplitContainer {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.absolute()
|
||||||
.size_full(),
|
.size_full(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user