⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/modules/ui_engine/widgets/splitter/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(tahuantinsuyu): UX pass — splitter, light wheel, scroll, zoom/pan, dock lateral
1//! `yahweh_widget_splitter` — `SplitContainer` genérico.
2//!
3//! Aloja `n` hijos `AnyView` con flex weights individuales y un divisor
4//! arrastrable entre cada par adyacente. Dirección horizontal o vertical
5//! intercambiable. Emite [`SplitEvent::FlexChanged`] cuando un drag termina,
6//! para que el host (LayoutHost / DemoApp) persista los flex.
7//!
8//! El SplitContainer NO conoce a sus hijos: los recibe vía
9//! `set_children(Vec<ChildSlot>)`. Eso permite que el LayoutHost reuse las
10//! mismas instancias cuando el JSON cambia el `kind` del contenedor (Split
11//! → Tabs → Tiled) — los AnyView siguen vivos, solo cambia su contenedor.
12//!
13//! Drag: usamos el patrón canónico de gpui (ver `data_table.rs` ejemplo) —
14//! cada divider tiene un `canvas(prepaint, paint)` que en su paint registra
15//! handlers de `MouseDown / MouseMove / MouseUp` a nivel de window vía
16//! `window.on_mouse_event`. Esto garantiza que el drag continúa aunque el
17//! cursor salga del divider.
18
19use std::cell::RefCell;
20use std::rc::Rc;
21
22use gpui::{
23 App, Bounds, Context, EventEmitter, IntoElement, Length, MouseButton, MouseDownEvent,
24 MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, Window, canvas, div, prelude::*, px,
25};
26
27use yahweh_core::{LayoutDirection, NodeId};
28use yahweh_theme::Theme;
29pub use yahweh_widget_container_core::ChildSlot;
30
31#[derive(Clone, Debug)]
32pub enum SplitEvent {
33 /// Un drag actualizó los flex weights. Se emite UNA vez por movimiento
34 /// (cada frame durante un drag), con los IDs y flex finales de los dos
35 /// hijos adyacentes al divisor.
36 FlexChanged {
37 left_id: NodeId,
38 right_id: NodeId,
39 left_flex: f32,
40 right_flex: f32,
41 },
42 /// El drag terminó (mouseup). Útil para persistir batched.
43 DragEnd,
44}
45
46// =====================================================================
47// Widget
48// =====================================================================
49
50/// Estado interno del drag activo. `divider_index` apunta al espacio entre
51/// `children[i]` y `children[i+1]`. Los snapshots `flex_*_initial` y
52/// `start_pos_main` se capturan en MouseDown — durante MouseMove se
53/// recalcula el flex desde el delta.
54struct DragState {
55 divider_index: usize,
56 start_pos_main: Pixels,
57 flex_left_initial: f32,
58 flex_right_initial: f32,
59 /// Longitud total del SplitContainer en el eje principal al iniciar el
60 /// drag (capturada de `bounds`). Usada para convertir delta_px ↔
61 /// delta_flex preservando el sum total.
62 total_main_size: Pixels,
63 total_flex_initial: f32,
64}
65
66pub struct SplitContainer {
67 children: Vec<ChildSlot>,
68 direction: LayoutDirection,
69 drag: Option<DragState>,
70 /// Bounds del frame anterior. Capturados vía canvas absolute en cada
71 /// paint. Lo usamos al iniciar drag para resolver `total_main_size`.
72 bounds: Rc<RefCell<Option<Bounds<Pixels>>>>,
73}
74
75impl EventEmitter<SplitEvent> for SplitContainer {}
76
77impl SplitContainer {
78 pub fn new(direction: LayoutDirection, cx: &mut Context<Self>) -> Self {
79 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
80 Self {
81 children: Vec::new(),
82 direction,
83 drag: None,
84 bounds: Rc::new(RefCell::new(None)),
85 }
86 }
87
88 pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
89 self.children = children;
90 cx.notify();
91 }
92
93 pub fn set_direction(&mut self, direction: LayoutDirection, cx: &mut Context<Self>) {
94 if self.direction != direction {
95 self.direction = direction;
96 cx.notify();
97 }
98 }
99
100 pub fn direction(&self) -> LayoutDirection {
101 self.direction
102 }
103
104 pub fn children(&self) -> &[ChildSlot] {
105 &self.children
106 }
107
108 // -------- Drag handlers --------
109
110 fn start_drag(&mut self, divider_index: usize, position: Point<Pixels>) {
111 if divider_index >= self.children.len().saturating_sub(1) {
112 return;
113 }
114 let bounds = match *self.bounds.borrow() {
115 Some(b) => b,
116 None => return,
117 };
118 let raw_main = main_axis(self.direction, bounds.size.width, bounds.size.height);
119 // Restamos el espacio que ocupan los divisores — son fixed-size en el
120 // eje principal, no participan del flex. El "espacio disponible
121 // para flex" es lo que importa para convertir delta_px → delta_flex.
122 let dividers_total = px(DIVIDER_HIT_ZONE) * (self.children.len().saturating_sub(1) as f32);
123 let total_main = raw_main - dividers_total;
124 if total_main <= px(0.0) {
125 return;
126 }
127
128 let total_flex: f32 = self.children.iter().map(|c| c.flex.max(0.0)).sum();
129 let total_flex = total_flex.max(0.001);
130
131 let start_main = main_axis_pt(self.direction, position);
132
133 self.drag = Some(DragState {
134 divider_index,
135 start_pos_main: start_main,
136 flex_left_initial: self.children[divider_index].flex,
137 flex_right_initial: self.children[divider_index + 1].flex,
138 total_main_size: total_main,
139 total_flex_initial: total_flex,
140 });
141 }
142
143 fn continue_drag(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
144 let Some(drag) = &self.drag else { return };
145 let drag_idx = drag.divider_index;
146 if drag_idx + 1 >= self.children.len() {
147 return;
148 }
149
150 let cur_main = main_axis_pt(self.direction, position);
151 let delta_px = cur_main - drag.start_pos_main;
152 // delta_flex = delta_px / total_main_size * total_flex_initial.
153 let total_main_f = f32::from(drag.total_main_size).max(1.0);
154 let delta_flex = (f32::from(delta_px) / total_main_f) * drag.total_flex_initial;
155
156 const MIN_FLEX: f32 = 0.05;
157 let new_left = (drag.flex_left_initial + delta_flex).max(MIN_FLEX);
158 let new_right = (drag.flex_right_initial - delta_flex).max(MIN_FLEX);
159
160 // Solo aplicamos si NINGUNO se aplastó al mínimo y se "comió" el
161 // delta — eso significa que el drag llegó al borde de un hijo.
162 let fits = (drag.flex_left_initial + delta_flex) >= MIN_FLEX
163 && (drag.flex_right_initial - delta_flex) >= MIN_FLEX;
164 if !fits {
165 // Recortamos: aplicamos los mínimos pero no propagamos delta más
166 // allá del límite. Resultado: el divisor "frena" en el borde.
167 }
168
169 self.children[drag_idx].flex = new_left;
170 self.children[drag_idx + 1].flex = new_right;
171
172 let left_id = self.children[drag_idx].id.clone();
173 let right_id = self.children[drag_idx + 1].id.clone();
174 cx.emit(SplitEvent::FlexChanged {
175 left_id,
176 right_id,
177 left_flex: new_left,
178 right_flex: new_right,
179 });
180 cx.notify();
181 }
182
183 fn end_drag(&mut self, cx: &mut Context<Self>) {
184 if self.drag.take().is_some() {
185 cx.emit(SplitEvent::DragEnd);
186 cx.notify();
187 }
188 }
189}
190
191// =====================================================================
192// Helpers de eje
193// =====================================================================
194
195fn main_axis(dir: LayoutDirection, w: Pixels, h: Pixels) -> Pixels {
196 match dir {
197 LayoutDirection::Horizontal => w,
198 _ => h,
199 }
200}
201
202fn main_axis_pt(dir: LayoutDirection, p: Point<Pixels>) -> Pixels {
203 match dir {
204 LayoutDirection::Horizontal => p.x,
205 _ => p.y,
206 }
207}
208
209// =====================================================================
210// Render
211// =====================================================================
212
213/// Espesor visible de la franja del divisor (la barrita coloreada).
214const DIVIDER_VISUAL: f32 = 4.0;
215/// Espesor total de la zona interactiva: cursor + handlers de mouse. Más
216/// generosa que el visual para no pelearse con el usuario al apuntar a
217/// una banda de 4px. El visual queda centrado dentro del hit zone.
218const DIVIDER_HIT_ZONE: f32 = 12.0;
219
220impl Render for SplitContainer {
221 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
222 let theme = Theme::global(cx).clone();
223 let direction = self.direction;
224 let entity = cx.entity();
225 let bounds_holder = self.bounds.clone();
226
227 let total_flex: f32 = self
228 .children
229 .iter()
230 .map(|c| c.flex.max(0.0))
231 .sum::<f32>()
232 .max(0.001);
233
234 // Root flex container.
235 let mut root = div().size_full().relative();
236 root = match direction {
237 LayoutDirection::Horizontal => root.flex().flex_row(),
238 _ => root.flex().flex_col(),
239 };
240
241 // Canvas absolute para capturar bounds del SplitContainer en cada
242 // frame. No participa del flex (absolute), no captura clicks
243 // (canvas sin id es no-interactivo).
244 root = root.child({
245 let bounds_holder = bounds_holder.clone();
246 canvas(
247 move |bounds, _w, _cx| {
248 *bounds_holder.borrow_mut() = Some(bounds);
249 },
250 |_, _, _, _| {},
251 )
252 .absolute()
253 .size_full()
254 });
255
256 // Children + dividers entre cada par.
257 let n = self.children.len();
258 for (i, child) in self.children.iter().enumerate() {
259 let weight = (child.flex.max(0.0) / total_flex).max(0.001);
260
261 let mut item = div().relative();
262 // flex_grow fraccional — el helper `flex_grow()` solo setea 1.0,
263 // así que vamos directo al campo subyacente para repartir
264 // proporcionalmente según el `flex` de cada slot.
265 item.style().flex_grow = Some(weight);
266 item.style().flex_shrink = Some(1.0);
267
268 // CRUCIAL: flex-basis = 0 (no `auto`). El default `auto` toma
269 // el min-content de cada hijo como punto de partida; cuando un
270 // hijo tiene contenido grande (canvas con WHEEL_SIZE fijo, un
271 // panel con muchos controles en flex_wrap, etc.) la suma de
272 // bases excede el contenedor y flexbox abandona el reparto
273 // por flex-grow para usar shrink proporcional a la basis —
274 // resultado: el ratio 1:4 que pide el host se ignora y el
275 // hijo más liviano (p. ej. el tree) se aplasta a 0px. Con
276 // basis=0 todo el espacio es "free space" y el ratio se
277 // respeta sin importar el contenido.
278 item.style().flex_basis = Some(Length::Definite(px(0.0).into()));
279
280 // Floor de shrink: con basis=0 esto rara vez importa, pero lo
281 // dejamos por defensa contra contenidos que fuercen min-size
282 // intrínseco (uniform_list mide su primera row, etc.).
283 item.style().min_size.width = Some(Length::Definite(px(0.0).into()));
284 item.style().min_size.height = Some(Length::Definite(px(0.0).into()));
285
286 // Eje cruzado: full. Eje principal: lo decide flex.
287 let item = match direction {
288 LayoutDirection::Horizontal => item.h_full(),
289 _ => item.w_full(),
290 }
291 .overflow_hidden()
292 .child(child.view.clone());
293
294 root = root.child(item);
295
296 // Divisor entre i e i+1 (no después del último).
297 if i + 1 < n {
298 let divider_idx = i;
299 let entity_for_canvas = entity.clone();
300
301 let is_active = self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx);
302 let visual_bg = if is_active {
303 theme.accent_strong
304 } else {
305 theme.border_strong
306 };
307
308 // Visual: la franja fina coloreada que el usuario ve.
309 let visual = match direction {
310 LayoutDirection::Horizontal => div()
311 .w(px(DIVIDER_VISUAL))
312 .h_full()
313 .bg(visual_bg),
314 _ => div()
315 .w_full()
316 .h(px(DIVIDER_VISUAL))
317 .bg(visual_bg),
318 };
319
320 // Hit zone: wrapper transparente más ancho que captura
321 // cursor y handlers de mouse. Centra el visual con flex.
322 // `relative` para que el canvas hijo (absolute) se ancle
323 // al wrapper y reporte sus bounds correctos.
324 let mut divider = div().relative().flex().items_center().justify_center();
325 divider = match direction {
326 LayoutDirection::Horizontal => divider
327 .w(px(DIVIDER_HIT_ZONE))
328 .h_full()
329 .cursor_ew_resize(),
330 _ => divider
331 .w_full()
332 .h(px(DIVIDER_HIT_ZONE))
333 .cursor_ns_resize(),
334 };
335 divider = divider.child(visual);
336
337 // Canvas con handlers de drag a nivel de window — su
338 // bounds = bounds del wrapper (hit zone completo), así
339 // que el `canvas_bounds.contains` acepta clicks en todo
340 // el ancho del hit zone, no solo sobre el visual.
341 let divider = divider.child(
342 canvas(
343 |_, _, _| (),
344 move |canvas_bounds: Bounds<Pixels>, _, window, _| {
345 // MouseDown sobre el divisor → start_drag.
346 window.on_mouse_event({
347 let entity = entity_for_canvas.clone();
348 move |ev: &MouseDownEvent, _, _w: &mut Window, cx: &mut App| {
349 if ev.button != MouseButton::Left {
350 return;
351 }
352 if !canvas_bounds.contains(&ev.position) {
353 return;
354 }
355 entity.update(cx, |this, _| {
356 this.start_drag(divider_idx, ev.position);
357 });
358 }
359 });
360
361 // MouseMove anywhere → continue_drag si hay drag.
362 window.on_mouse_event({
363 let entity = entity_for_canvas.clone();
364 move |ev: &MouseMoveEvent, _, _w: &mut Window, cx: &mut App| {
365 if !ev.dragging() {
366 return;
367 }
368 entity.update(cx, |this, cx| {
369 if this.drag.is_some() {
370 this.continue_drag(ev.position, cx);
371 }
372 });
373 }
374 });
375
376 // MouseUp anywhere → end_drag.
377 window.on_mouse_event({
378 let entity = entity_for_canvas.clone();
379 move |_: &MouseUpEvent, _, _w: &mut Window, cx: &mut App| {
380 entity.update(cx, |this, cx| this.end_drag(cx));
381 }
382 });
383 },
384 )
385 .absolute()
386 .size_full(),
387 );
388
389 root = root.child(divider);
390 }
391 }
392
393 root
394 }
395}