ui/tree.rs
1//! Tree view — nested rows with disclosure, indent guides and arrow keys.
2//!
3//! bezel cannot walk your tree. It has no idea what a node is, and a trait or a
4//! callback to find out would be a data model this library does not want to
5//! own — so the app flattens its own tree into the rows that are visible right
6//! now, which it has to do anyway to render them.
7//!
8//! That is not a compromise, because **a depth-annotated flat list is a
9//! complete navigation model**. Everything a tree does falls out of [`Row`]
10//! with no parent pointers and no tree walk: down and up are neighbouring
11//! indices, a first child is simply the next row, and a parent is the nearest
12//! row above with a smaller depth. [`step`] is that, and nothing else.
13//!
14//! ```ignore
15//! ui::tree::init(cx); // once, at startup
16//!
17//! // Each frame: flatten what is open, paint it, and let `step` answer the keys.
18//! let rows = self.flatten(); // Vec<(Row, label)>
19//! tree().children(rows.iter().enumerate().map(|(index, (row, label))| {
20//! tree_row(&theme, row, self.selected == Some(index), self.cursor == index)
21//! .id(("row", index))
22//! .child(label.clone())
23//! }))
24//! ```
25//!
26//! Expansion stays with the app because it *is* app data — a file tree's open
27//! folders often outlive the window — so [`step`] reports an intent, and the app
28//! applies it to the set it owns.
29
30use gpui::{App, KeyBinding, actions, div, prelude::*, px};
31
32use theme::{TextStyle, Theme, Typeset, hairline};
33
34use crate::widgets::Layout;
35
36/// One visible row: how deep it sits, and whether it is a branch.
37///
38/// `expanded` is `None` for a leaf — which is a different thing from a closed
39/// branch, and the difference is what stops `right` pretending a file can open.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub struct Row {
42 pub depth: usize,
43 pub expanded: Option<bool>,
44}
45
46impl Row {
47 pub fn leaf(depth: usize) -> Self {
48 Self {
49 depth,
50 expanded: None,
51 }
52 }
53
54 pub fn branch(depth: usize, expanded: bool) -> Self {
55 Self {
56 depth,
57 expanded: Some(expanded),
58 }
59 }
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum Direction {
64 Up,
65 Down,
66 Left,
67 Right,
68}
69
70/// What a keypress meant. An intent rather than a mutation: only the app can
71/// expand a row, because only the app knows what is under it.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum Move {
74 To(usize),
75 Expand(usize),
76 Collapse(usize),
77}
78
79/// The row `index` hangs under: the nearest row above it with a smaller depth.
80///
81/// Nearest, not previous — the row directly above is usually a sibling, and
82/// often the last leaf of a sibling's whole subtree. Walking up until the depth
83/// actually drops is what skips all of that.
84pub fn parent_of(rows: &[Row], index: usize) -> Option<usize> {
85 let depth = rows.get(index)?.depth;
86 rows[..index]
87 .iter()
88 .rposition(|candidate| candidate.depth < depth)
89}
90
91/// What an arrow key means at `cursor`, or `None` when it means nothing.
92///
93/// Neither end wraps. A menu wraps because it is a ring of choices; a tree is a
94/// document, and arriving back at the top because you pressed down once too
95/// often loses your place in it.
96pub fn step(rows: &[Row], cursor: usize, direction: Direction) -> Option<Move> {
97 let row = rows.get(cursor)?;
98 match direction {
99 Direction::Up => cursor.checked_sub(1).map(Move::To),
100 Direction::Down => (cursor + 1 < rows.len()).then_some(Move::To(cursor + 1)),
101 // A closed branch opens; an open one steps into it — and its first child
102 // is just the next row, because the list is already in visible order.
103 Direction::Right => match row.expanded {
104 Some(false) => Some(Move::Expand(cursor)),
105 Some(true) => (cursor + 1 < rows.len()).then_some(Move::To(cursor + 1)),
106 None => None,
107 },
108 // The mirror: an open branch closes, everything else goes up a level.
109 Direction::Left => match row.expanded {
110 Some(true) => Some(Move::Collapse(cursor)),
111 _ => parent_of(rows, cursor).map(Move::To),
112 },
113 }
114}
115
116actions!(bezel_tree, [SelectPrevious, SelectNext, Collapse, Expand]);
117
118/// The key context a tree claims.
119pub const KEY_CONTEXT: &str = "Tree";
120
121/// Install the bindings — [`bindings`], bound. Call once, alongside
122/// [`crate::input::init`].
123pub fn init(cx: &mut App) {
124 cx.bind_keys(bindings());
125}
126
127/// The tree's four arrows, as data, so an app can have it without having to
128/// take it — see [`crate::keys`] for layering over it or taking a chord
129/// away.
130///
131/// The handlers are the app's — like [`crate::focus`]
132/// and unlike the menubar, a tree cannot handle them itself, because applying a
133/// [`Move`] means touching the app's own expansion set. What bezel does here is
134/// name the four chords everyone already agrees on, once.
135pub fn bindings() -> Vec<KeyBinding> {
136 let mut bindings = Vec::new();
137 let ctx = Some(KEY_CONTEXT);
138 bindings.extend([
139 KeyBinding::new("up", SelectPrevious, ctx),
140 KeyBinding::new("down", SelectNext, ctx),
141 KeyBinding::new("left", Collapse, ctx),
142 KeyBinding::new("right", Expand, ctx),
143 ]);
144
145 bindings
146}
147
148/// How far one level of nesting indents.
149pub const INDENT: f32 = 14.0;
150/// Width of the chevron column, kept by leaves as well so their labels line up
151/// with their siblings' rather than sliding under them.
152const CHEVRON: f32 = 16.0;
153
154/// The container. Rows go in it; scrolling is the caller's, via
155/// [`crate::scroll`].
156pub fn tree() -> gpui::Div {
157 div().flex().flex_col().w_full()
158}
159
160/// One row: its guides, its chevron, and then whatever the caller puts in it.
161///
162/// `selected` is what the app considers chosen; `cursor` is where the keyboard
163/// is. Two tones, because a tree shows both at once.
164pub fn tree_row(theme: &Theme, row: &Row, selected: bool, cursor: bool) -> gpui::Div {
165 let mut frame = div()
166 .flex()
167 .flex_row()
168 .items_center()
169 .w_full()
170 .py(px(3.0))
171 .pr(px(8.0))
172 .text_style(TextStyle::Callout)
173 .cursor_pointer();
174 frame = if selected {
175 frame.bg(theme.card_selected_bg()).text_color(theme.text)
176 } else if cursor {
177 frame.bg(theme::wash(0.05)).text_color(theme.text)
178 } else {
179 frame.text_color(theme.text_muted)
180 };
181 frame
182 // One segment per ancestor level, drawn by the row it passes through:
183 // the line is continuous down the page without any element having to
184 // span rows or know its neighbours.
185 .children((0..row.depth).map(|_| {
186 div()
187 .flex_none()
188 .w(px(INDENT))
189 .h(px(18.0))
190 .border_l_1()
191 .border_color(hairline(0.08))
192 }))
193 .child(
194 div()
195 .flex_none()
196 .w(px(CHEVRON))
197 .flex()
198 .items_center()
199 .justify_center()
200 .when_some(row.expanded, |slot, expanded| {
201 slot.child(theme.disclosure(expanded))
202 }),
203 )
204}