⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd
crates/modules/ui_engine/widgets/tiled/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
chore: monorepo inicial con arje + minga + yahweh absorbidos
1//! `yahweh_widget_tiled` — `TiledContainer`.
2//!
3//! Distribuye `n` hijos en una grilla auto-calculada: `cols = ⌈√n⌉`,
4//! `rows = ⌈n/cols⌉`. Las celdas tienen el mismo peso.
5//!
6//! ## Drag-to-swap
7//!
8//! Cada tile tiene una franja superior de 18px (la "title bar") con cursor
9//! de `move`: arrastrarla dispara un swap. Anatomía:
10//!
11//! 1. Mouse down sobre la title bar de tile A → record `dragging_idx = A`.
12//! 2. Mouse move (window-level) actualiza `hover_idx` chequeando bounds
13//! de cada tile capturados en cada paint.
14//! 3. Mouse up → si `hover_idx != dragging_idx` y son válidos, emitimos
15//! [`TiledEvent::Reordered { from, to }`] para que el LayoutHost lo
16//! persista (swap_children en el LayoutModel).
17//!
18//! Mientras dura el drag, el tile origen pinta un overlay translúcido y el
19//! tile destino se resalta con border `accent_strong`. Sin el LayoutHost
20//! persistiendo, el reorder es solo emisión — el `set_children` que viene
21//! después del rebuild aplica el orden nuevo.
22//!
23//! Filosofía: el TiledContainer NO mantiene un orden propio en `Vec`, ni
24//! reordena `self.children` localmente. Toda mutación va vía el modelo
25//! (single source of truth). Eso garantiza que persiste, sobrevive a
26//! reload y se ve consistente con el JSON.
27
28use std::cell::RefCell;
29use std::rc::Rc;
30
31use gpui::{
32 App, Bounds, Context, EventEmitter, IntoElement, Length, MouseButton, MouseDownEvent,
33 MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, Window, canvas, div, prelude::*, px,
34};
35
36use yahweh_core::NodeId;
37use yahweh_theme::Theme;
38use yahweh_widget_container_core::ChildSlot;
39
40#[derive(Clone, Debug)]
41#[allow(dead_code)]
42pub enum TiledEvent {
43 /// Drag-and-drop terminó con un swap entre el tile en `from_index` y
44 /// el de `to_index`. Los IDs van por valor para que el suscriptor no
45 /// tenga que reconsultar el container.
46 Reordered {
47 from_index: usize,
48 from_id: NodeId,
49 to_index: usize,
50 to_id: NodeId,
51 },
52}
53
54#[derive(Clone, Debug)]
55struct DragState {
56 from_index: usize,
57 /// Índice sobre el que el cursor está actualmente. `None` si está
58 /// fuera de cualquier tile.
59 hover_index: Option<usize>,
60}
61
62pub struct TiledContainer {
63 children: Vec<ChildSlot>,
64 drag: Option<DragState>,
65 /// Bounds de cada tile en el último frame, indexados por posición en
66 /// `children`. Capturados via canvas en cada tile para que el drag
67 /// pueda hit-testear sin reflexión sobre el árbol.
68 tile_bounds: Rc<RefCell<Vec<Option<Bounds<Pixels>>>>>,
69}
70
71impl EventEmitter<TiledEvent> for TiledContainer {}
72
73impl TiledContainer {
74 pub fn new(cx: &mut Context<Self>) -> Self {
75 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
76 Self {
77 children: Vec::new(),
78 drag: None,
79 tile_bounds: Rc::new(RefCell::new(Vec::new())),
80 }
81 }
82
83 pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
84 // Resize el vector de bounds para que el index sea válido en cada
85 // paint; los bounds reales se llenan en el canvas.
86 let n = children.len();
87 self.tile_bounds.borrow_mut().resize(n, None);
88 self.children = children;
89 cx.notify();
90 }
91
92 pub fn children(&self) -> &[ChildSlot] {
93 &self.children
94 }
95
96 fn start_drag(&mut self, idx: usize, cx: &mut Context<Self>) {
97 if idx >= self.children.len() {
98 return;
99 }
100 self.drag = Some(DragState {
101 from_index: idx,
102 hover_index: None,
103 });
104 cx.notify();
105 }
106
107 fn update_hover(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
108 let Some(drag) = &mut self.drag else { return };
109 // Hit-test contra los bounds capturados.
110 let bounds = self.tile_bounds.borrow();
111 let mut new_hover = None;
112 for (i, b) in bounds.iter().enumerate() {
113 if let Some(b) = b {
114 if b.contains(&position) {
115 new_hover = Some(i);
116 break;
117 }
118 }
119 }
120 if drag.hover_index != new_hover {
121 drag.hover_index = new_hover;
122 cx.notify();
123 }
124 }
125
126 fn end_drag(&mut self, cx: &mut Context<Self>) {
127 let Some(drag) = self.drag.take() else { return };
128 if let Some(to) = drag.hover_index {
129 if to != drag.from_index
130 && to < self.children.len()
131 && drag.from_index < self.children.len()
132 {
133 let from_id = self.children[drag.from_index].id.clone();
134 let to_id = self.children[to].id.clone();
135 cx.emit(TiledEvent::Reordered {
136 from_index: drag.from_index,
137 from_id,
138 to_index: to,
139 to_id,
140 });
141 }
142 }
143 cx.notify();
144 }
145}
146
147const TILE_GAP: f32 = 4.0;
148const TITLE_BAR_HEIGHT: f32 = 20.0;
149
150impl Render for TiledContainer {
151 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
152 let theme = Theme::global(cx).clone();
153 let n = self.children.len();
154
155 if n == 0 {
156 return div()
157 .size_full()
158 .bg(theme.bg_panel.clone())
159 .flex()
160 .items_center()
161 .justify_center()
162 .text_size(px(11.0))
163 .text_color(theme.fg_muted)
164 .child("(tiled vacío)");
165 }
166
167 let cols = (n as f32).sqrt().ceil() as usize;
168 let cols = cols.max(1);
169 let rows = (n + cols - 1) / cols;
170 let drag = self.drag.clone();
171 let entity = cx.entity();
172 let bounds_holder = self.tile_bounds.clone();
173
174 let mut col_container = div()
175 .size_full()
176 .bg(theme.bg_app.clone())
177 .flex()
178 .flex_col()
179 .gap(px(TILE_GAP))
180 .p(px(TILE_GAP));
181
182 for r in 0..rows {
183 let mut row_div = div()
184 .w_full()
185 .flex()
186 .flex_row()
187 .flex_grow()
188 .gap(px(TILE_GAP));
189 row_div.style().min_size.height = Some(Length::Definite(px(0.0).into()));
190
191 for c in 0..cols {
192 let idx = r * cols + c;
193 let mut tile = div().h_full();
194 tile.style().flex_grow = Some(1.0);
195 tile.style().flex_shrink = Some(1.0);
196 tile.style().min_size.width = Some(Length::Definite(px(0.0).into()));
197
198 let is_dragging_src = drag.as_ref().map(|d| d.from_index) == Some(idx);
199 let is_drop_target = drag.as_ref().and_then(|d| d.hover_index) == Some(idx)
200 && drag.as_ref().map(|d| d.from_index) != Some(idx);
201
202 let border_color = if is_drop_target {
203 theme.accent_strong
204 } else {
205 theme.border
206 };
207
208 let tile = tile
209 .bg(theme.bg_panel.clone())
210 .border_1()
211 .border_color(border_color)
212 .rounded(px(4.0))
213 .overflow_hidden();
214
215 let tile = if let Some(child) = self.children.get(idx) {
216 let child = child.clone();
217 let opacity = if is_dragging_src { 0.45 } else { 1.0 };
218
219 // Canvas que captura el bounds del tile entero (para
220 // hit-test del drop target).
221 let bounds_holder_inner = bounds_holder.clone();
222 let bounds_canvas = canvas(
223 move |bounds, _w, _cx| {
224 let mut b = bounds_holder_inner.borrow_mut();
225 if idx < b.len() {
226 b[idx] = Some(bounds);
227 }
228 },
229 |_, _, _, _| {},
230 )
231 .absolute()
232 .size_full();
233
234 // Title bar — drag handle. Canvas con window-level
235 // mouse handlers, mismo patrón que SplitContainer.
236 let entity_for_canvas = entity.clone();
237 let title_canvas = canvas(
238 |_, _, _| (),
239 move |canvas_bounds: Bounds<Pixels>, _, window, _| {
240 window.on_mouse_event({
241 let entity = entity_for_canvas.clone();
242 move |ev: &MouseDownEvent, _, _w: &mut Window, cx: &mut App| {
243 if ev.button != MouseButton::Left {
244 return;
245 }
246 if !canvas_bounds.contains(&ev.position) {
247 return;
248 }
249 entity.update(cx, |this, cx| this.start_drag(idx, cx));
250 }
251 });
252 window.on_mouse_event({
253 let entity = entity_for_canvas.clone();
254 move |ev: &MouseMoveEvent, _, _w: &mut Window, cx: &mut App| {
255 if !ev.dragging() {
256 return;
257 }
258 entity.update(cx, |this, cx| {
259 if this.drag.is_some() {
260 this.update_hover(ev.position, cx);
261 }
262 });
263 }
264 });
265 window.on_mouse_event({
266 let entity = entity_for_canvas.clone();
267 move |_: &MouseUpEvent, _, _w: &mut Window, cx: &mut App| {
268 entity.update(cx, |this, cx| this.end_drag(cx));
269 }
270 });
271 },
272 )
273 .size_full();
274
275 // El layout del tile: title bar arriba (con label +
276 // canvas drag), body abajo (con la AnyView del child).
277 let label_text = child
278 .label
279 .clone()
280 .unwrap_or_else(|| child.id.as_str().to_string());
281
282 tile.flex().flex_col().opacity(opacity).child(
283 div()
284 .h(px(TITLE_BAR_HEIGHT))
285 .w_full()
286 .px(px(8.0))
287 .bg(theme.bg_panel_alt.clone())
288 .border_b_1()
289 .border_color(theme.border)
290 .text_size(px(10.0))
291 .text_color(theme.fg_muted)
292 .cursor_move()
293 .relative()
294 .child(
295 // Label + drag canvas (canvas absolute
296 // sobre la franja entera).
297 div()
298 .flex()
299 .items_center()
300 .h_full()
301 .child(gpui::SharedString::from(label_text)),
302 )
303 .child(title_canvas),
304 )
305 .child(
306 // Body — overlay con bounds canvas + el AnyView.
307 div()
308 .flex_grow()
309 .min_h(px(0.0))
310 .relative()
311 .child(bounds_canvas)
312 .child(child.view.clone()),
313 )
314 .into_any_element()
315 } else {
316 tile.opacity(0.35).into_any_element()
317 };
318
319 row_div = row_div.child(tile);
320 }
321
322 col_container = col_container.child(row_div);
323 }
324
325 col_container
326 }
327}