Skip to main content

data_beans/interactive/
list_tui.rs

1//! A list to browse, flat or a tree: the names of a matrix's rows or
2//! columns (`row-names`, `column-names`), or the hierarchy of a file
3//! (`list-h5`, `list-zarr`).
4//!
5//! `/` filters (case-insensitive, a regular expression when the text is
6//! one), Space marks, `a` marks every entry shown and `u` clears the marks,
7//! `w` writes the marked entries (or, with none marked, those shown) to a
8//! file, one per line, and `y` copies them (or the entry under the cursor)
9//! to the clipboard. In a tree, Enter or →/← open and close a group; an
10//! entry's name is then its path from the root.
11
12use legume_numeric::matrix::common_io::write_lines;
13use ratatui::crossterm::event::{KeyCode, KeyEvent};
14use ratatui::layout::{Constraint, Layout};
15use ratatui::style::Modifier;
16use ratatui::text::{Line, Span};
17use ratatui::widgets::Paragraph;
18use ratatui::Frame;
19use regex::{Regex, RegexBuilder};
20
21use super::ui::{copy, header, help_line, input_line, title_bar, Screen, Tui, DIM, HIGHLIGHT};
22
23/// One entry: its name, how deep it sits (0 at the top), and a note shown
24/// beside it (an array's shape and type, say).
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Item {
27    pub name: String,
28    pub depth: usize,
29    pub detail: String,
30}
31
32impl Item {
33    pub fn new(name: impl Into<String>, depth: usize, detail: impl Into<String>) -> Self {
34        Self {
35            name: name.into(),
36            depth,
37            detail: detail.into(),
38        }
39    }
40}
41
42/// What the footer is doing.
43#[derive(Debug, Clone, PartialEq, Eq)]
44enum Mode {
45    Browse,
46    Filter,
47    /// Asking where to write, with the text so far.
48    Write(String),
49}
50
51/// The browser, independent of the terminal so it can be tested.
52pub struct ListView {
53    badge: &'static str,
54    title: String,
55    items: Vec<Item>,
56    /// Each item's path from the root, `/`-joined: what is written.
57    paths: Vec<String>,
58    /// Each item's parent, and whether it has children.
59    parent: Vec<Option<usize>>,
60    has_children: Vec<bool>,
61    folded: Vec<bool>,
62    marked: Vec<bool>,
63    filter: String,
64    /// Items on screen, in order.
65    view: Vec<usize>,
66    cursor: usize,
67    offset: usize,
68    /// Rows the list had when last drawn, for paging.
69    page: usize,
70    /// Lines shown under the list (a summary the command printed).
71    notes: Vec<String>,
72    /// Where `w` writes unless told otherwise.
73    write_to: String,
74    mode: Mode,
75    status: Option<String>,
76    quit: bool,
77    session_keys: Vec<(String, String)>,
78}
79
80impl ListView {
81    /// `items` in display order, a child right after its parent at one
82    /// level deeper. `badge` names the view; `write_to` is where `w` offers
83    /// to write.
84    pub fn new(badge: &'static str, title: &str, items: Vec<Item>, write_to: &str) -> Self {
85        let n = items.len();
86        let mut parent = vec![None; n];
87        let mut has_children = vec![false; n];
88        let mut paths = Vec::with_capacity(n);
89        // The open ancestors of the current item, by depth.
90        let mut stack: Vec<usize> = Vec::new();
91        for (i, it) in items.iter().enumerate() {
92            stack.truncate(it.depth);
93            if let Some(&p) = stack.last() {
94                parent[i] = Some(p);
95                has_children[p] = true;
96            }
97            paths.push(match parent[i] {
98                Some(p) => format!("{}/{}", paths[p], it.name.trim_end_matches('/')),
99                None => it.name.trim_end_matches('/').to_string(),
100            });
101            stack.push(i);
102        }
103        let mut view = Self {
104            badge,
105            title: title.to_string(),
106            items,
107            paths,
108            parent,
109            has_children,
110            folded: vec![false; n],
111            marked: vec![false; n],
112            filter: String::new(),
113            view: Vec::new(),
114            cursor: 0,
115            offset: 0,
116            page: 20,
117            notes: Vec::new(),
118            write_to: write_to.to_string(),
119            mode: Mode::Browse,
120            status: None,
121            quit: false,
122            session_keys: super::session_keys(),
123        };
124        view.refilter();
125        view
126    }
127
128    /// Flat names, one entry each.
129    pub fn names(badge: &'static str, title: &str, names: &[Box<str>], write_to: &str) -> Self {
130        let items = names.iter().map(|n| Item::new(&**n, 0, "")).collect();
131        Self::new(badge, title, items, write_to)
132    }
133
134    /// Lines to show under the list.
135    pub fn with_notes(mut self, notes: Vec<String>) -> Self {
136        self.notes = notes;
137        self
138    }
139
140    fn is_tree(&self) -> bool {
141        self.has_children.iter().any(|&c| c)
142    }
143
144    fn pattern(&self) -> Option<Regex> {
145        if self.filter.is_empty() {
146            return None;
147        }
148        RegexBuilder::new(&self.filter)
149            .case_insensitive(true)
150            .build()
151            .or_else(|_| {
152                RegexBuilder::new(&regex::escape(&self.filter))
153                    .case_insensitive(true)
154                    .build()
155            })
156            .ok()
157    }
158
159    /// Recompute the items shown: under a filter, the matches and their
160    /// ancestors, folds aside; else every item no folded group hides.
161    fn refilter(&mut self) {
162        let keep = self.view.get(self.cursor).copied();
163        self.view = match self.pattern() {
164            Some(re) => {
165                let mut shown = vec![false; self.items.len()];
166                for i in 0..self.items.len() {
167                    if re.is_match(&self.items[i].name) {
168                        let mut at = Some(i);
169                        while let Some(j) = at.filter(|&j| !shown[j]) {
170                            shown[j] = true;
171                            at = self.parent[j];
172                        }
173                    }
174                }
175                (0..self.items.len()).filter(|&i| shown[i]).collect()
176            }
177            None => (0..self.items.len())
178                .filter(|&i| !self.hidden_by_fold(i))
179                .collect(),
180        };
181        self.cursor = keep
182            .and_then(|k| self.view.iter().position(|&i| i == k))
183            .unwrap_or(0);
184        self.offset = self.offset.min(self.cursor);
185    }
186
187    fn hidden_by_fold(&self, i: usize) -> bool {
188        let mut at = self.parent[i];
189        while let Some(p) = at {
190            if self.folded[p] {
191                return true;
192            }
193            at = self.parent[p];
194        }
195        false
196    }
197
198    fn selected(&self) -> Option<usize> {
199        self.view.get(self.cursor).copied()
200    }
201
202    fn move_by(&mut self, delta: isize) {
203        let last = self.view.len().saturating_sub(1) as isize;
204        self.cursor = (self.cursor as isize + delta).clamp(0, last) as usize;
205    }
206
207    fn marked_count(&self) -> usize {
208        self.marked.iter().filter(|&&m| m).count()
209    }
210
211    /// What `w` and `y` take: the marked entries, else those shown.
212    fn chosen(&self) -> Vec<Box<str>> {
213        let marked: Vec<usize> = (0..self.items.len()).filter(|&i| self.marked[i]).collect();
214        let from = if marked.is_empty() {
215            &self.view
216        } else {
217            &marked
218        };
219        from.iter()
220            .map(|&i| self.paths[i].as_str().into())
221            .collect()
222    }
223
224    fn set_fold(&mut self, folded: bool) {
225        if let Some(i) = self.selected().filter(|&i| self.has_children[i]) {
226            if self.folded[i] != folded {
227                self.folded[i] = folded;
228                self.refilter();
229            }
230        } else if folded {
231            // ← on a leaf goes to its group.
232            if let Some(p) = self.selected().and_then(|i| self.parent[i]) {
233                if let Some(at) = self.view.iter().position(|&j| j == p) {
234                    self.cursor = at;
235                }
236            }
237        }
238    }
239
240    fn write(&mut self, path: &str) {
241        let lines = self.chosen();
242        self.status = Some(match write_lines(&lines, path) {
243            Ok(()) => format!("wrote {} entries to {path}", lines.len()),
244            Err(e) => format!("not written: {e}"),
245        });
246    }
247
248    fn browse_key(&mut self, key: KeyEvent) {
249        self.status = None;
250        match key.code {
251            KeyCode::Char('q') | KeyCode::Esc => self.quit = true,
252            KeyCode::Down | KeyCode::Char('j') => self.move_by(1),
253            KeyCode::Up | KeyCode::Char('k') => self.move_by(-1),
254            KeyCode::PageDown => self.move_by(self.page as isize),
255            KeyCode::PageUp => self.move_by(-(self.page as isize)),
256            KeyCode::Home | KeyCode::Char('g') => self.cursor = 0,
257            KeyCode::End | KeyCode::Char('G') => self.move_by(isize::MAX / 2),
258            KeyCode::Char('/') => self.mode = Mode::Filter,
259            KeyCode::Char(' ') => {
260                if let Some(i) = self.selected() {
261                    self.marked[i] = !self.marked[i];
262                    self.move_by(1);
263                }
264            }
265            KeyCode::Char('a') => {
266                for &i in &self.view {
267                    self.marked[i] = true;
268                }
269            }
270            KeyCode::Char('u') => self.marked.fill(false),
271            KeyCode::Enter => {
272                if let Some(i) = self.selected().filter(|&i| self.has_children[i]) {
273                    self.folded[i] = !self.folded[i];
274                    self.refilter();
275                }
276            }
277            KeyCode::Right | KeyCode::Char('l') => self.set_fold(false),
278            KeyCode::Left | KeyCode::Char('h') => self.set_fold(true),
279            KeyCode::Char('w') => self.mode = Mode::Write(self.write_to.clone()),
280            KeyCode::Char('y') => {
281                let text = if self.marked_count() > 0 {
282                    self.chosen().join("\n")
283                } else {
284                    self.selected()
285                        .map(|i| self.paths[i].clone())
286                        .unwrap_or_default()
287                };
288                let n = text.lines().count();
289                copy(&text);
290                self.status = Some(format!("copied {n} to the clipboard"));
291            }
292            _ => {}
293        }
294    }
295
296    fn filter_key(&mut self, key: KeyEvent) {
297        match key.code {
298            KeyCode::Enter => self.mode = Mode::Browse,
299            KeyCode::Esc => {
300                self.filter.clear();
301                self.mode = Mode::Browse;
302                self.refilter();
303            }
304            KeyCode::Backspace => {
305                self.filter.pop();
306                self.refilter();
307            }
308            KeyCode::Char(c) => {
309                self.filter.push(c);
310                self.refilter();
311            }
312            _ => {}
313        }
314    }
315
316    fn write_key(&mut self, key: KeyEvent) {
317        let Mode::Write(text) = &mut self.mode else {
318            return;
319        };
320        match key.code {
321            KeyCode::Esc => self.mode = Mode::Browse,
322            KeyCode::Enter => {
323                let path = text.trim().to_string();
324                self.mode = Mode::Browse;
325                if !path.is_empty() {
326                    self.write_to.clone_from(&path);
327                    self.write(&path);
328                }
329            }
330            KeyCode::Backspace => {
331                text.pop();
332            }
333            KeyCode::Char(c) => text.push(c),
334            _ => {}
335        }
336    }
337
338    fn row(&self, i: usize, at_cursor: bool) -> Line<'static> {
339        let it = &self.items[i];
340        let mark = if self.marked[i] { "● " } else { "  " };
341        // A filter shows what matches inside a closed group too.
342        let closed = self.folded[i] && self.filter.is_empty();
343        let fold = match (self.has_children[i], closed) {
344            (true, true) => "▸ ",
345            (true, false) => "▾ ",
346            (false, _) if self.is_tree() => "  ",
347            _ => "",
348        };
349        let name_style = if at_cursor {
350            HIGHLIGHT.add_modifier(Modifier::REVERSED)
351        } else if self.marked[i] {
352            HIGHLIGHT
353        } else {
354            ratatui::style::Style::new()
355        };
356        let mut spans = vec![
357            Span::styled(mark, HIGHLIGHT),
358            Span::raw(format!("{}{fold}", "  ".repeat(it.depth))),
359            Span::styled(it.name.clone(), name_style),
360        ];
361        if !it.detail.is_empty() {
362            spans.push(Span::styled(format!("  {}", it.detail), DIM));
363        }
364        Line::from(spans)
365    }
366
367    fn footer(&self) -> Line<'static> {
368        match &self.mode {
369            Mode::Filter => input_line(
370                "filter: ",
371                &self.filter,
372                &[("enter", "keep"), ("esc", "clear")],
373            ),
374            Mode::Write(text) => {
375                input_line("write to: ", text, &[("enter", "write"), ("esc", "cancel")])
376            }
377            Mode::Browse => match &self.status {
378                Some(s) => Line::from(Span::styled(format!(" {s}"), HIGHLIGHT)),
379                None => {
380                    let mut keys = vec![("/", "filter"), ("space", "mark"), ("a", "all")];
381                    keys.push(("u", "unmark"));
382                    if self.is_tree() {
383                        keys.push(("enter", "fold"));
384                    }
385                    keys.extend([("w", "write"), ("y", "copy"), ("q", "quit")]);
386                    help_line(&keys)
387                }
388            },
389        }
390    }
391}
392
393impl Screen for ListView {
394    fn render(&mut self, frame: &mut Frame) {
395        let notes = self.notes.len().min(8) as u16;
396        let [top, list, below, foot] = Layout::vertical([
397            Constraint::Length(1),
398            Constraint::Fill(1),
399            Constraint::Length(if notes > 0 { notes + 1 } else { 0 }),
400            Constraint::Length(1),
401        ])
402        .areas(frame.area());
403        let mut extra = format!("{} entries", self.items.len());
404        if self.view.len() < self.items.len() {
405            extra.push_str(&format!(" · {} shown", self.view.len()));
406        }
407        let marked = self.marked_count();
408        if marked > 0 {
409            extra.push_str(&format!(" · {marked} marked"));
410        }
411        if !self.filter.is_empty() {
412            extra.push_str(&format!(" · /{}", self.filter));
413        }
414        title_bar(
415            frame,
416            top,
417            header(self.badge, &self.title, &extra),
418            &self.session_keys,
419        );
420        self.page = list.height.max(1) as usize;
421        if self.cursor < self.offset {
422            self.offset = self.cursor;
423        } else if self.cursor >= self.offset + self.page {
424            self.offset = self.cursor + 1 - self.page;
425        }
426        let lines: Vec<Line> = self
427            .view
428            .iter()
429            .enumerate()
430            .skip(self.offset)
431            .take(self.page)
432            .map(|(k, &i)| self.row(i, k == self.cursor))
433            .collect();
434        let lines = if lines.is_empty() {
435            vec![Line::from(Span::styled("  nothing matches", DIM))]
436        } else {
437            lines
438        };
439        frame.render_widget(Paragraph::new(lines), list);
440        if notes > 0 {
441            let lines: Vec<Line> = std::iter::once(Line::from(""))
442                .chain(
443                    self.notes
444                        .iter()
445                        .take(8)
446                        .map(|n| Line::from(Span::styled(format!(" {n}"), DIM))),
447                )
448                .collect();
449            frame.render_widget(Paragraph::new(lines), below);
450        }
451        frame.render_widget(self.footer(), foot);
452    }
453
454    fn handle_key(&mut self, key: KeyEvent) {
455        match self.mode {
456            Mode::Browse => self.browse_key(key),
457            Mode::Filter => self.filter_key(key),
458            Mode::Write(_) => self.write_key(key),
459        }
460    }
461
462    fn interrupt(&mut self) {
463        self.quit = true;
464    }
465
466    fn done(&self) -> bool {
467        self.quit
468    }
469
470    fn status(&self) -> Option<String> {
471        Some(format!("browsing {}", self.title))
472    }
473}
474
475/// Browse `view` on the screen of `tui` until `q`.
476pub fn browse_in(tui: &mut Tui<'_>, mut view: ListView) -> anyhow::Result<()> {
477    tui.run(&mut view)
478}
479
480#[cfg(test)]
481#[path = "tests/list_tui.rs"]
482mod tests;