Skip to main content

typ_panel_tree/
lib.rs

1use std::any::Any;
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind};
7use ratatui::buffer::Buffer;
8use ratatui::layout::Rect;
9use ratatui::style::Style;
10use ratatui::text::Line;
11use ratatui::widgets::{Block, Paragraph, Widget};
12use typ_core::{KeyChord, Panel, PanelEvent, RenderContext};
13
14/// One visible row of the tree.
15#[derive(Debug, Clone)]
16pub struct Entry {
17    pub path: PathBuf,
18    /// Nesting level below the root, used for indentation.
19    pub depth: usize,
20    pub is_dir: bool,
21}
22
23pub struct TreePanel {
24    root: PathBuf,
25    /// The flattened visible rows. Rebuilt whenever expansion changes, which is
26    /// cheap because only expanded directories are ever read.
27    entries: Vec<Entry>,
28    expanded: HashSet<PathBuf>,
29    selected: usize,
30    top_line: usize,
31    height: usize,
32}
33
34impl TreePanel {
35    pub fn new(root: &Path) -> Result<Self> {
36        let mut panel = Self {
37            root: root.to_path_buf(),
38            entries: Vec::new(),
39            expanded: HashSet::new(),
40            selected: 0,
41            top_line: 0,
42            height: 0,
43        };
44        panel.rebuild()?;
45        Ok(panel)
46    }
47
48    pub fn entry_count(&self) -> usize {
49        self.entries.len()
50    }
51
52    pub fn selected(&self) -> Option<&Path> {
53        self.entries.get(self.selected).map(|e| e.path.as_path())
54    }
55
56    pub fn depth_of_selection(&self) -> usize {
57        self.entries.get(self.selected).map_or(0, |e| e.depth)
58    }
59
60    pub fn root(&self) -> &Path {
61        &self.root
62    }
63
64    /// Rebuild the visible rows, keeping the selection on the same path where
65    /// that path is still visible.
66    fn rebuild(&mut self) -> Result<()> {
67        let previous = self.selected().map(Path::to_path_buf);
68        let mut entries = Vec::new();
69        collect(&self.root, 0, &self.expanded, &mut entries)?;
70        self.entries = entries;
71        self.selected = previous
72            .and_then(|p| self.entries.iter().position(|e| e.path == p))
73            .unwrap_or(self.selected)
74            .min(self.entries.len().saturating_sub(1));
75        Ok(())
76    }
77
78    fn move_selection(&mut self, delta: i32) {
79        let last = self.entries.len().saturating_sub(1) as i64;
80        self.selected = (self.selected as i64 + delta as i64).clamp(0, last) as usize;
81        self.scroll_to_selection();
82    }
83
84    fn scroll_to_selection(&mut self) {
85        if self.height == 0 {
86            return;
87        }
88        if self.selected < self.top_line {
89            self.top_line = self.selected;
90        } else if self.selected >= self.top_line + self.height {
91            self.top_line = self.selected - self.height + 1;
92        }
93    }
94
95    fn set_expanded(&mut self, path: PathBuf, expand: bool) -> Vec<PanelEvent> {
96        if expand {
97            self.expanded.insert(path);
98        } else {
99            // Collapsing a directory also hides everything under it, so drop
100            // the descendants' expansion state rather than leaving it to
101            // resurface the next time this directory is opened.
102            self.expanded.retain(|p| !p.starts_with(&path));
103        }
104        match self.rebuild() {
105            Ok(()) => vec![PanelEvent::NeedsRedraw],
106            Err(e) => vec![PanelEvent::Notify {
107                level: typ_core::NotifyLevel::Error,
108                message: format!("{e:#}"),
109            }],
110        }
111    }
112
113    /// Open a file, or toggle a directory.
114    fn activate(&mut self) -> Vec<PanelEvent> {
115        let Some(entry) = self.entries.get(self.selected).cloned() else {
116            return Vec::new();
117        };
118        if entry.is_dir {
119            let expand = !self.expanded.contains(&entry.path);
120            self.set_expanded(entry.path, expand)
121        } else {
122            vec![PanelEvent::OpenFile {
123                path: entry.path,
124                line: 0,
125                col: 0,
126            }]
127        }
128    }
129
130    /// The list area inside the panel's border.
131    fn list_area(area: Rect) -> Rect {
132        Block::bordered().inner(area)
133    }
134}
135
136/// Depth-first walk that descends only into expanded directories.
137/// Directories sort before files, each alphabetically.
138fn collect(
139    dir: &Path,
140    depth: usize,
141    expanded: &HashSet<PathBuf>,
142    out: &mut Vec<Entry>,
143) -> Result<()> {
144    let mut children: Vec<Entry> = std::fs::read_dir(dir)
145        .with_context(|| format!("reading {}", dir.display()))?
146        .filter_map(|e| e.ok())
147        .map(|e| Entry {
148            is_dir: e.path().is_dir(),
149            path: e.path(),
150            depth,
151        })
152        .collect();
153    children.sort_by_key(|e| {
154        (
155            !e.is_dir,
156            e.path
157                .file_name()
158                .and_then(|n| n.to_str())
159                .unwrap_or("")
160                .to_lowercase(),
161        )
162    });
163
164    for child in children {
165        let descend = child.is_dir && expanded.contains(&child.path);
166        let path = child.path.clone();
167        out.push(child);
168        if descend {
169            collect(&path, depth + 1, expanded, out)?;
170        }
171    }
172    Ok(())
173}
174
175impl Panel for TreePanel {
176    fn name(&self) -> &'static str {
177        "tree"
178    }
179
180    fn title(&self) -> String {
181        self.root
182            .file_name()
183            .and_then(|n| n.to_str())
184            .unwrap_or("/")
185            .to_string()
186    }
187
188    fn render(&mut self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
189        let border = if ctx.is_focused {
190            ctx.theme.border_focused
191        } else {
192            ctx.theme.border
193        };
194        let block = Block::bordered()
195            .border_style(Style::default().fg(border))
196            .title(self.title());
197        let inner = block.inner(area);
198        block.render(area, buf);
199
200        self.height = inner.height as usize;
201        let end = (self.top_line + self.height).min(self.entries.len());
202        let lines: Vec<Line> = (self.top_line..end)
203            .map(|i| {
204                let entry = &self.entries[i];
205                let name = entry
206                    .path
207                    .file_name()
208                    .and_then(|n| n.to_str())
209                    .unwrap_or("?");
210                // A caret rather than a folder glyph: it says both "this is a
211                // directory" and "this is its state" in one cell, and needs no
212                // font support.
213                let marker = if !entry.is_dir {
214                    "  "
215                } else if self.expanded.contains(&entry.path) {
216                    "v "
217                } else {
218                    "> "
219                };
220                let label = format!("{}{marker}{name}", "  ".repeat(entry.depth));
221                let style = if i == self.selected {
222                    Style::default()
223                        .fg(ctx.theme.selection_fg)
224                        .bg(ctx.theme.selection_bg)
225                } else {
226                    Style::default().fg(ctx.theme.fg)
227                };
228                Line::styled(label, style)
229            })
230            .collect();
231        Paragraph::new(lines)
232            .style(Style::default().bg(ctx.theme.bg))
233            .render(inner, buf);
234    }
235
236    fn handle_key(&mut self, chord: KeyChord) -> Vec<PanelEvent> {
237        match chord.raw.code {
238            KeyCode::Down => self.move_selection(1),
239            KeyCode::Up => self.move_selection(-1),
240            KeyCode::Enter => return self.activate(),
241            KeyCode::Right => {
242                if let Some(e) = self.entries.get(self.selected).cloned()
243                    && e.is_dir
244                    && !self.expanded.contains(&e.path)
245                {
246                    return self.set_expanded(e.path, true);
247                }
248            }
249            KeyCode::Left => {
250                if let Some(e) = self.entries.get(self.selected).cloned()
251                    && e.is_dir
252                    && self.expanded.contains(&e.path)
253                {
254                    return self.set_expanded(e.path, false);
255                }
256            }
257            _ => return Vec::new(),
258        }
259        vec![PanelEvent::NeedsRedraw]
260    }
261
262    fn handle_mouse(&mut self, event: MouseEvent, panel_area: Rect) -> Vec<PanelEvent> {
263        if event.kind != MouseEventKind::Down(MouseButton::Left) {
264            return Vec::new();
265        }
266        let inner = Self::list_area(panel_area);
267        let row = event.row.saturating_sub(inner.y) as usize;
268        let idx = self.top_line + row;
269        if idx >= self.entries.len() {
270            return Vec::new();
271        }
272        // Click selects; clicking the already-selected entry activates it,
273        // matching how GUI file trees behave.
274        if idx == self.selected {
275            return self.activate();
276        }
277        self.selected = idx;
278        vec![PanelEvent::NeedsRedraw]
279    }
280
281    fn handle_scroll(&mut self, delta: i32, _panel_area: Rect) -> Vec<PanelEvent> {
282        let max_top = self.entries.len().saturating_sub(self.height.max(1));
283        self.top_line = (self.top_line as i64 + delta as i64).clamp(0, max_top as i64) as usize;
284        vec![PanelEvent::NeedsRedraw]
285    }
286
287    fn as_any(&self) -> &dyn Any {
288        self
289    }
290    fn as_any_mut(&mut self) -> &mut dyn Any {
291        self
292    }
293}