⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd
crates/modules/ui_engine/widgets/tree/src/lib.rs
Historia
Quién tocó qué
crudo
chore: monorepo inicial con arje + minga + yahweh absorbidos
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}