1use std::{cell::RefCell, ops::Range, rc::Rc};
2
3use gpui::{
4 div, prelude::FluentBuilder as _, uniform_list, App, Context, ElementId, Entity, FocusHandle,
5 InteractiveElement as _, IntoElement, KeyBinding, ListSizingBehavior, MouseButton,
6 ParentElement, Render, RenderOnce, SharedString, StyleRefinement, Styled,
7 UniformListScrollHandle, Window,
8};
9
10use crate::{
11 actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp},
12 list::ListItem,
13 scroll::{Scrollbar, ScrollbarState},
14 StyledExt,
15};
16
17const CONTEXT: &str = "Tree";
18pub(crate) fn init(cx: &mut App) {
19 cx.bind_keys([
20 KeyBinding::new("up", SelectUp, Some(CONTEXT)),
21 KeyBinding::new("down", SelectDown, Some(CONTEXT)),
22 KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
23 KeyBinding::new("right", SelectRight, Some(CONTEXT)),
24 ]);
25}
26
27pub fn tree<R>(state: &Entity<TreeState>, render_item: R) -> Tree
50where
51 R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static,
52{
53 Tree::new(state, render_item)
54}
55
56struct TreeItemState {
57 expanded: bool,
58 disabled: bool,
59}
60
61#[derive(Clone)]
63pub struct TreeItem {
64 pub id: SharedString,
65 pub label: SharedString,
66 pub children: Vec<TreeItem>,
67 state: Rc<RefCell<TreeItemState>>,
68}
69
70#[derive(Clone)]
72pub struct TreeEntry {
73 item: TreeItem,
74 depth: usize,
75}
76
77impl TreeEntry {
78 #[inline]
80 pub fn item(&self) -> &TreeItem {
81 &self.item
82 }
83
84 #[inline]
86 pub fn depth(&self) -> usize {
87 self.depth
88 }
89
90 #[inline]
91 fn is_root(&self) -> bool {
92 self.depth == 0
93 }
94
95 #[inline]
97 pub fn is_folder(&self) -> bool {
98 self.item.is_folder()
99 }
100
101 #[inline]
103 pub fn is_expanded(&self) -> bool {
104 self.item.is_expanded()
105 }
106
107 #[inline]
108 pub fn is_disabled(&self) -> bool {
109 self.item.is_disabled()
110 }
111}
112
113impl TreeItem {
114 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
125 Self {
126 id: id.into(),
127 label: label.into(),
128 children: Vec::new(),
129 state: Rc::new(RefCell::new(TreeItemState {
130 expanded: false,
131 disabled: false,
132 })),
133 }
134 }
135
136 pub fn child(mut self, child: TreeItem) -> Self {
138 self.children.push(child);
139 self
140 }
141
142 pub fn children(mut self, children: impl Into<Vec<TreeItem>>) -> Self {
144 self.children.extend(children.into());
145 self
146 }
147
148 pub fn expanded(self, expanded: bool) -> Self {
150 self.state.borrow_mut().expanded = expanded;
151 self
152 }
153
154 pub fn disabled(self, disabled: bool) -> Self {
156 self.state.borrow_mut().disabled = disabled;
157 self
158 }
159
160 #[inline]
162 pub fn is_folder(&self) -> bool {
163 self.children.len() > 0
164 }
165
166 pub fn is_disabled(&self) -> bool {
168 self.state.borrow().disabled
169 }
170
171 #[inline]
173 pub fn is_expanded(&self) -> bool {
174 self.state.borrow().expanded
175 }
176}
177
178pub struct TreeState {
180 focus_handle: FocusHandle,
181 entries: Vec<TreeEntry>,
182 scrollbar_state: ScrollbarState,
183 scroll_handle: UniformListScrollHandle,
184 selected_ix: Option<usize>,
185 render_item: Rc<dyn Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem>,
186}
187
188impl TreeState {
189 pub fn new(cx: &mut App) -> Self {
191 Self {
192 selected_ix: None,
193 focus_handle: cx.focus_handle(),
194 scrollbar_state: ScrollbarState::default(),
195 scroll_handle: UniformListScrollHandle::default(),
196 entries: Vec::new(),
197 render_item: Rc::new(|_, _, _, _, _| ListItem::new(0)),
198 }
199 }
200
201 pub fn items(mut self, items: impl Into<Vec<TreeItem>>) -> Self {
203 let items = items.into();
204 self.entries.clear();
205 for item in items.into_iter() {
206 self.add_entry(item, 0);
207 }
208 self
209 }
210
211 pub fn set_items(&mut self, items: impl Into<Vec<TreeItem>>, cx: &mut Context<Self>) {
213 let items = items.into();
214 self.entries.clear();
215 for item in items.into_iter() {
216 self.add_entry(item, 0);
217 }
218 self.selected_ix = None;
219 cx.notify();
220 }
221
222 pub fn selected_index(&self) -> Option<usize> {
224 self.selected_ix
225 }
226
227 pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
229 self.selected_ix = ix;
230 cx.notify();
231 }
232
233 pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) {
234 self.scroll_handle.scroll_to_item(ix, strategy);
235 }
236
237 pub fn selected_entry(&self) -> Option<&TreeEntry> {
239 self.selected_ix.and_then(|ix| self.entries.get(ix))
240 }
241
242 fn add_entry(&mut self, item: TreeItem, depth: usize) {
243 self.entries.push(TreeEntry {
244 item: item.clone(),
245 depth,
246 });
247 if item.is_expanded() {
248 for child in &item.children {
249 self.add_entry(child.clone(), depth + 1);
250 }
251 }
252 }
253
254 fn toggle_expand(&mut self, ix: usize) {
255 let Some(entry) = self.entries.get_mut(ix) else {
256 return;
257 };
258 if !entry.is_folder() {
259 return;
260 }
261
262 entry.item.state.borrow_mut().expanded = !entry.is_expanded();
263 self.rebuild_entries();
264 }
265
266 fn rebuild_entries(&mut self) {
267 let root_items: Vec<TreeItem> = self
268 .entries
269 .iter()
270 .filter(|e| e.is_root())
271 .map(|e| e.item.clone())
272 .collect();
273 self.entries.clear();
274 for item in root_items.into_iter() {
275 self.add_entry(item, 0);
276 }
277 }
278
279 fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
280 if let Some(selected_ix) = self.selected_ix {
281 if let Some(entry) = self.entries.get(selected_ix) {
282 if entry.is_folder() {
283 self.toggle_expand(selected_ix);
284 cx.notify();
285 }
286 }
287 }
288 }
289
290 fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
291 if let Some(selected_ix) = self.selected_ix {
292 if let Some(entry) = self.entries.get(selected_ix) {
293 if entry.is_folder() && entry.is_expanded() {
294 self.toggle_expand(selected_ix);
295 cx.notify();
296 }
297 }
298 }
299 }
300
301 fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
302 if let Some(selected_ix) = self.selected_ix {
303 if let Some(entry) = self.entries.get(selected_ix) {
304 if entry.is_folder() && !entry.is_expanded() {
305 self.toggle_expand(selected_ix);
306 cx.notify();
307 }
308 }
309 }
310 }
311
312 fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
313 let mut selected_ix = self.selected_ix.unwrap_or(0);
314
315 if selected_ix > 0 {
316 selected_ix = selected_ix - 1;
317 } else {
318 selected_ix = self.entries.len().saturating_sub(1);
319 }
320
321 self.selected_ix = Some(selected_ix);
322 self.scroll_handle
323 .scroll_to_item(selected_ix, gpui::ScrollStrategy::Top);
324 cx.notify();
325 }
326
327 fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
328 let mut selected_ix = self.selected_ix.unwrap_or(0);
329 if selected_ix + 1 < self.entries.len() {
330 selected_ix = selected_ix + 1;
331 } else {
332 selected_ix = 0;
333 }
334
335 self.selected_ix = Some(selected_ix);
336 self.scroll_handle
337 .scroll_to_item(selected_ix, gpui::ScrollStrategy::Bottom);
338 cx.notify();
339 }
340
341 fn on_entry_click(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) {
342 self.selected_ix = Some(ix);
343 self.toggle_expand(ix);
344 cx.notify();
345 }
346}
347
348impl Render for TreeState {
349 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
350 let render_item = self.render_item.clone();
351
352 div()
353 .id("tree-state")
354 .size_full()
355 .relative()
356 .child(
357 uniform_list("entries", self.entries.len(), {
358 cx.processor(move |state, visible_range: Range<usize>, window, cx| {
359 let mut items = Vec::with_capacity(visible_range.len());
360 for ix in visible_range {
361 let entry = &state.entries[ix];
362 let selected = Some(ix) == state.selected_ix;
363 let item = (render_item)(ix, entry, selected, window, cx);
364
365 let el = div()
366 .id(ix)
367 .child(item.disabled(entry.item().is_disabled()).selected(selected))
368 .when(!entry.item().is_disabled(), |this| {
369 this.on_mouse_down(
370 MouseButton::Left,
371 cx.listener({
372 move |this, _, window, cx| {
373 this.on_entry_click(ix, window, cx);
374 }
375 }),
376 )
377 });
378
379 items.push(el)
380 }
381
382 items
383 })
384 })
385 .flex_grow()
386 .size_full()
387 .track_scroll(self.scroll_handle.clone())
388 .with_sizing_behavior(ListSizingBehavior::Auto)
389 .into_any_element(),
390 )
391 .child(
392 div()
393 .absolute()
394 .top_0()
395 .right_0()
396 .bottom_0()
397 .w(Scrollbar::width())
398 .child(Scrollbar::vertical(
399 &self.scrollbar_state,
400 &self.scroll_handle,
401 )),
402 )
403 }
404}
405
406#[derive(IntoElement)]
408pub struct Tree {
409 id: ElementId,
410 state: Entity<TreeState>,
411 style: StyleRefinement,
412 render_item: Rc<dyn Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem>,
413}
414
415impl Tree {
416 pub fn new<R>(state: &Entity<TreeState>, render_item: R) -> Self
417 where
418 R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static,
419 {
420 Self {
421 id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
422 state: state.clone(),
423 style: StyleRefinement::default(),
424 render_item: Rc::new(move |ix, item, selected, window, app| {
425 render_item(ix, item, selected, window, app)
426 }),
427 }
428 }
429}
430
431impl Styled for Tree {
432 fn style(&mut self) -> &mut StyleRefinement {
433 &mut self.style
434 }
435}
436
437impl RenderOnce for Tree {
438 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
439 let focus_handle = self.state.read(cx).focus_handle.clone();
440
441 self.state
442 .update(cx, |state, _| state.render_item = self.render_item);
443
444 div()
445 .id(self.id)
446 .key_context(CONTEXT)
447 .track_focus(&focus_handle)
448 .on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
449 .on_action(window.listener_for(&self.state, TreeState::on_action_left))
450 .on_action(window.listener_for(&self.state, TreeState::on_action_right))
451 .on_action(window.listener_for(&self.state, TreeState::on_action_up))
452 .on_action(window.listener_for(&self.state, TreeState::on_action_down))
453 .size_full()
454 .child(self.state)
455 .refine_style(&self.style)
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use indoc::indoc;
462
463 use super::TreeState;
464 use gpui::AppContext as _;
465
466 fn assert_entries(entries: &Vec<super::TreeEntry>, expected: &str) {
467 let actual: Vec<String> = entries
468 .iter()
469 .map(|e| {
470 let mut s = String::new();
471 s.push_str(&" ".repeat(e.depth));
472 s.push_str(e.item().label.as_str());
473 s
474 })
475 .collect();
476 let actual = actual.join("\n");
477 assert_eq!(actual.trim(), expected.trim());
478 }
479
480 #[gpui::test]
481 fn test_tree_entry(cx: &mut gpui::TestAppContext) {
482 use super::TreeItem;
483
484 let items = vec![
485 TreeItem::new("src", "src")
486 .expanded(true)
487 .child(
488 TreeItem::new("src/ui", "ui")
489 .expanded(true)
490 .child(TreeItem::new("src/ui/button.rs", "button.rs"))
491 .child(TreeItem::new("src/ui/icon.rs", "icon.rs"))
492 .child(TreeItem::new("src/ui/mod.rs", "mod.rs")),
493 )
494 .child(TreeItem::new("src/lib.rs", "lib.rs")),
495 TreeItem::new("Cargo.toml", "Cargo.toml"),
496 TreeItem::new("Cargo.lock", "Cargo.lock").disabled(true),
497 TreeItem::new("README.md", "README.md"),
498 ];
499
500 let state = cx.new(|cx| TreeState::new(cx).items(items));
501 state.update(cx, |state, _| {
502 assert_entries(
503 &state.entries,
504 indoc! {
505 r#"
506 src
507 ui
508 button.rs
509 icon.rs
510 mod.rs
511 lib.rs
512 Cargo.toml
513 Cargo.lock
514 README.md
515 "#
516 },
517 );
518
519 let entry = state.entries.get(0).unwrap();
520 assert_eq!(entry.depth(), 0);
521 assert_eq!(entry.is_root(), true);
522 assert_eq!(entry.is_folder(), true);
523 assert_eq!(entry.is_expanded(), true);
524
525 let entry = state.entries.get(1).unwrap();
526 assert_eq!(entry.depth(), 1);
527 assert_eq!(entry.is_root(), false);
528 assert_eq!(entry.is_folder(), true);
529 assert_eq!(entry.is_expanded(), true);
530 assert_eq!(entry.item().label.as_str(), "ui");
531
532 state.toggle_expand(1);
533 let entry = state.entries.get(1).unwrap();
534 assert_eq!(entry.is_expanded(), false);
535 assert_entries(
536 &state.entries,
537 indoc! {
538 r#"
539 src
540 ui
541 lib.rs
542 Cargo.toml
543 Cargo.lock
544 README.md
545 "#
546 },
547 );
548 })
549 }
550}