tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd crates/modules/ui_engine/widgets/tree/src/lib.rs Rust · 415 líneas · 13.4 KB Historia Quién tocó qué crudo

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

1//! `yahweh_widget_tree` — TreeView genérico, agnóstico del dominio.
2//!
3//! Anatomía: el host (FileExplorer, DatabaseExplorer, …) calcula una lista
4//! plana `Vec<TreeRow>` por DFS y la empuja con `set_rows`. El TreeView solo
5//! renderea, captura interacciones y emite [`TreeEvent`]. Todo lo de
6//! dominio (qué carga al expandir un branch, qué hacer en doble click, etc)
7//! lo decide el host suscribiéndose vía `cx.subscribe`.
8//!
9//! Esta es la pieza que reemplaza al `gioser_tree::Tree` de Makepad. La
10//! diferencia clave es de plomería: en GPUI no hay un global action queue
11//! ni Buttons que capten clicks indebidamente — cada `div` tiene su
12//! `.on_click` propio y la propagación se detiene explícitamente. Lo que
13//! peleamos en Makepad acá no existe.
14
15use std::collections::HashMap;
16use std::ops::Range;
17
18use gpui::{
19    ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, IntoElement, MouseButton,
20    MouseDownEvent, Pixels, Point, Render, SharedString, Window, div, prelude::*, px,
21    uniform_list,
22};
23use yahweh_theme::Theme;
24
25// =====================================================================
26// Modelo público
27// =====================================================================
28
29/// Identificador opaco de una fila. Wrapper sobre `String` — el host elige
30/// la representación (path, primary key, GUID). El TreeView lo trata como
31/// dato opaco y lo usa de key del HashMap interno.
32#[derive(Clone, Debug, Hash, PartialEq, Eq)]
33pub struct RowId(pub String);
34
35impl RowId {
36    pub fn new(s: impl Into<String>) -> Self {
37        Self(s.into())
38    }
39
40    pub fn as_str(&self) -> &str {
41        &self.0
42    }
43}
44
45impl From<String> for RowId {
46    fn from(s: String) -> Self {
47        Self(s)
48    }
49}
50
51impl From<&str> for RowId {
52    fn from(s: &str) -> Self {
53        Self(s.to_string())
54    }
55}
56
57impl std::fmt::Display for RowId {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.write_str(&self.0)
60    }
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
64pub enum RowKind {
65    Branch,
66    #[default]
67    Leaf,
68}
69
70#[derive(Clone, Debug, Default)]
71pub struct TreeRow {
72    pub id: RowId,
73    pub label: String,
74    pub depth: u32,
75    pub kind: RowKind,
76    /// Solo aplica a `Branch`. El TreeView NO muta este campo — el host lo
77    /// pasa derivado de su propio `expanded: HashSet`.
78    pub expanded: bool,
79    /// Icono opcional (emoji o glyph) que se renderea entre chevron y label.
80    pub icon: Option<String>,
81}
82
83impl Default for RowId {
84    fn default() -> Self {
85        Self(String::new())
86    }
87}
88
89/// Eventos que el TreeView emite hacia su parent (`cx.subscribe(&tree, …)`).
90#[derive(Clone, Debug)]
91pub enum TreeEvent {
92    /// Click primario sobre el cuerpo de la fila (NO el chevron). El
93    /// TreeView ya actualizó su `active_id` internamente — esto es
94    /// notificación.
95    RowClicked(RowId),
96    /// Doble click sobre el cuerpo. Para Branch se emite además el toggle.
97    RowDoubleClicked(RowId),
98    /// Click en chevron, o doble click sobre Branch.
99    ChevronToggled(RowId),
100    /// Right-click. `id == None` cuando fue área vacía debajo de la última
101    /// fila. La posición es absoluta para que el host posicione su menú.
102    ContextMenuRequested {
103        id: Option<RowId>,
104        position: Point<Pixels>,
105    },
106    /// Cambio del `active_id` interno (por click, set_active externo, etc).
107    /// Se emite incluso cuando el cambio fue inducido externamente.
108    ActiveChanged(Option<RowId>),
109}
110
111// =====================================================================
112// Widget
113// =====================================================================
114
115pub struct TreeView {
116    rows: Vec<TreeRow>,
117    /// Mapa id → índice en `rows`. Se reconstruye en cada `set_rows`. Útil
118    /// para resolver `id → row` en O(1) cuando vienen acciones desde un row.
119    index: HashMap<RowId, usize>,
120    /// Fila activa (cursor row).
121    active_id: Option<RowId>,
122    /// Marker colors externos (cross-container highlighting).
123    selected: HashMap<RowId, Hsla>,
124
125    /// Id estable del elemento raíz para GPUI — lo necesita `uniform_list`
126    /// para mantener el scroll state entre frames.
127    list_id: SharedString,
128}
129
130impl EventEmitter<TreeEvent> for TreeView {}
131
132impl TreeView {
133    /// Crea un TreeView vacío. El parámetro `id` es libre — se usa solo
134    /// para identificar el `uniform_list` interno (debe ser único por
135    /// instancia). Ej.: `"file-tree"`, `"db-tree"`.
136    pub fn new(id: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
137        // Observar el theme global — cuando cambia, redibujamos para que el
138        // hover/active/marker reflejen la paleta nueva sin esperar el próximo
139        // evento de input.
140        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
141
142        Self {
143            rows: Vec::new(),
144            index: HashMap::new(),
145            active_id: None,
146            selected: HashMap::new(),
147            list_id: id.into(),
148        }
149    }
150
151    /// API pública: el host pushea las filas. Triggerea redraw.
152    pub fn set_rows(&mut self, rows: Vec<TreeRow>, cx: &mut Context<Self>) {
153        self.index = rows
154            .iter()
155            .enumerate()
156            .map(|(i, r)| (r.id.clone(), i))
157            .collect();
158        self.rows = rows;
159        cx.notify();
160    }
161
162    pub fn rows(&self) -> &[TreeRow] {
163        &self.rows
164    }
165
166    pub fn set_active(&mut self, id: Option<RowId>, cx: &mut Context<Self>) {
167        if self.active_id != id {
168            self.active_id = id.clone();
169            cx.emit(TreeEvent::ActiveChanged(id));
170            cx.notify();
171        }
172    }
173
174    pub fn active_id(&self) -> Option<&RowId> {
175        self.active_id.as_ref()
176    }
177
178    pub fn set_selected(&mut self, sel: HashMap<RowId, Hsla>, cx: &mut Context<Self>) {
179        self.selected = sel;
180        cx.notify();
181    }
182
183    pub fn add_selected(&mut self, id: RowId, color: Hsla, cx: &mut Context<Self>) {
184        self.selected.insert(id, color);
185        cx.notify();
186    }
187
188    pub fn remove_selected(&mut self, id: &RowId, cx: &mut Context<Self>) {
189        if self.selected.remove(id).is_some() {
190            cx.notify();
191        }
192    }
193
194    // ----- internos -----
195
196    fn handle_row_click(&mut self, id: RowId, click: &ClickEvent, cx: &mut Context<Self>) {
197        // Activar.
198        let new_active = Some(id.clone());
199        if self.active_id != new_active {
200            self.active_id = new_active.clone();
201            cx.emit(TreeEvent::ActiveChanged(new_active));
202        }
203        cx.emit(TreeEvent::RowClicked(id.clone()));
204
205        if click.click_count() >= 2 {
206            cx.emit(TreeEvent::RowDoubleClicked(id.clone()));
207            // Doble click sobre Branch: toggle implícito.
208            if let Some(row) = self.index.get(&id).and_then(|i| self.rows.get(*i)) {
209                if matches!(row.kind, RowKind::Branch) {
210                    cx.emit(TreeEvent::ChevronToggled(id));
211                }
212            }
213        }
214        cx.notify();
215    }
216
217    fn handle_chevron_click(&mut self, id: RowId, _click: &ClickEvent, cx: &mut Context<Self>) {
218        cx.emit(TreeEvent::ChevronToggled(id));
219    }
220
221    fn handle_right_click(
222        &mut self,
223        id: Option<RowId>,
224        event: &MouseDownEvent,
225        cx: &mut Context<Self>,
226    ) {
227        cx.emit(TreeEvent::ContextMenuRequested {
228            id,
229            position: event.position,
230        });
231    }
232}
233
234// =====================================================================
235// Render
236// =====================================================================
237
238const ROW_HEIGHT: f32 = 22.0;
239const INDENT_PX: f32 = 14.0;
240const CHEVRON_PX: f32 = 14.0;
241
242impl Render for TreeView {
243    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
244        let theme = Theme::global(cx).clone();
245        let row_count = self.rows.len();
246        let entity = cx.entity();
247
248        // Snapshot inmutable para que el closure de uniform_list pueda
249        // accederlo sin tomar prestado `self`.
250        let rows = self.rows.clone();
251        let active_id = self.active_id.clone();
252        let selected = self.selected.clone();
253        let list_id: ElementId = self.list_id.clone().into();
254
255        div()
256            .id("yahweh-tree-root")
257            .key_context("YahwehTree")
258            .size_full()
259            .bg(theme.bg_panel.clone())
260            .text_color(theme.fg_text)
261            // Right-click sobre área vacía (debajo de las rows) — sin id de
262            // row. La capa de rows captura su propio right-click y stoppea
263            // propagación, así que esto solo se dispara en el "fondo".
264            .on_mouse_down(
265                MouseButton::Right,
266                cx.listener({
267                    move |this, e: &MouseDownEvent, _, cx| {
268                        this.handle_right_click(None, e, cx);
269                    }
270                }),
271            )
272            .child(
273                uniform_list(list_id, row_count, move |range: Range<usize>, _w, _cx| {
274                    range
275                        .filter_map(|i| rows.get(i).cloned())
276                        .map(|row| {
277                            render_row(
278                                row,
279                                &theme,
280                                &active_id,
281                                &selected,
282                                entity.clone(),
283                            )
284                        })
285                        .collect()
286                })
287                .size_full(),
288            )
289    }
290}
291
292// =====================================================================
293// Render por fila — fuera del `impl Render` para mantener el tamaño
294// manejable y aislar el closure de uniform_list.
295// =====================================================================
296
297fn render_row(
298    row: TreeRow,
299    theme: &Theme,
300    active_id: &Option<RowId>,
301    selected: &HashMap<RowId, Hsla>,
302    entity: Entity<TreeView>,
303) -> impl IntoElement {
304    let id_for_chev = row.id.clone();
305    let id_for_body = row.id.clone();
306    let id_for_ctx = row.id.clone();
307
308    let is_active = active_id.as_ref() == Some(&row.id);
309    let marker = selected.get(&row.id).copied();
310
311    let chevron_glyph = match (row.kind, row.expanded) {
312        (RowKind::Branch, true) => "▾",
313        (RowKind::Branch, false) => "▸",
314        (RowKind::Leaf, _) => " ",
315    };
316    let icon = row.icon.clone().unwrap_or_default();
317    let label = row.label.clone();
318    let depth = row.depth as f32;
319    let is_branch = matches!(row.kind, RowKind::Branch);
320
321    // Background del row. Capas: marker (si hay) → active → hover (gestionado
322    // por gpui via .hover()).
323    let row_bg = if is_active {
324        Some(theme.bg_row_active)
325    } else {
326        marker
327    };
328
329    // Element id estable por fila — uniform_list es virtualizado, los ids
330    // tienen que ser únicos para que GPUI re-use el cache de hitboxes.
331    let element_id: ElementId = SharedString::from(format!("row::{}", row.id)).into();
332
333    let mut row_div = div()
334        .id(element_id)
335        .flex()
336        .flex_row()
337        .items_center()
338        .h(px(ROW_HEIGHT))
339        .w_full()
340        .pl(px(depth * INDENT_PX))
341        .text_size(px(13.0))
342        .hover(|s| s.bg(theme.bg_row_hover));
343
344    if let Some(bg) = row_bg {
345        row_div = row_div.bg(bg);
346    }
347
348    // Chevron — área propia, click stop_propagation para no disparar el
349    // body click.
350    let chevron_id: ElementId =
351        SharedString::from(format!("chev::{}", id_for_chev)).into();
352    let chevron = {
353        let entity = entity.clone();
354        let id = id_for_chev.clone();
355        div()
356            .id(chevron_id)
357            .w(px(CHEVRON_PX))
358            .h_full()
359            .flex()
360            .items_center()
361            .justify_center()
362            .text_color(theme.fg_muted)
363            .text_size(px(11.0))
364            .child(SharedString::from(chevron_glyph.to_string()))
365            .when(is_branch, |this| {
366                this.on_click(move |click, _w, cx| {
367                    cx.stop_propagation();
368                    entity.update(cx, |tree, cx| {
369                        tree.handle_chevron_click(id.clone(), click, cx);
370                    });
371                })
372            })
373    };
374
375    // Body — icono opcional + label, captura el click primario.
376    let body = {
377        let entity_body = entity.clone();
378        let entity_ctx = entity.clone();
379        let id_body = id_for_body.clone();
380        let id_ctx = id_for_ctx.clone();
381        let body_id: ElementId =
382            SharedString::from(format!("body::{}", id_for_body)).into();
383
384        let mut content = div()
385            .id(body_id)
386            .flex()
387            .flex_row()
388            .items_center()
389            .gap(px(4.0))
390            .px(px(4.0))
391            .flex_grow()
392            .h_full()
393            .on_click(move |click, _w, cx| {
394                entity_body.update(cx, |tree, cx| {
395                    tree.handle_row_click(id_body.clone(), click, cx);
396                });
397            })
398            .on_mouse_down(
399                MouseButton::Right,
400                move |e: &MouseDownEvent, _w, cx| {
401                    cx.stop_propagation();
402                    entity_ctx.update(cx, |tree, cx| {
403                        tree.handle_right_click(Some(id_ctx.clone()), e, cx);
404                    });
405                },
406            );
407
408        if !icon.is_empty() {
409            content = content.child(SharedString::from(icon.clone()));
410        }
411        content.child(SharedString::from(label.clone()))
412    };
413
414    row_div.child(chevron).child(body)
415}