tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd crates/modules/ui_engine/widgets/tiled/src/lib.rs Rust · 327 siq'ikuna · 12.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: monorepo inicial con arje + minga + yahweh absorbidos 53dbdf0f1 · Sergio ·

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}