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