1use 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
44enum Mode {
45 Browse,
46 Filter,
47 Write(String),
49}
50
51pub struct ListView {
53 badge: &'static str,
54 title: String,
55 items: Vec<Item>,
56 paths: Vec<String>,
58 parent: Vec<Option<usize>>,
60 has_children: Vec<bool>,
61 folded: Vec<bool>,
62 marked: Vec<bool>,
63 filter: String,
64 view: Vec<usize>,
66 cursor: usize,
67 offset: usize,
68 page: usize,
70 notes: Vec<String>,
72 write_to: String,
74 mode: Mode,
75 status: Option<String>,
76 quit: bool,
77 session_keys: Vec<(String, String)>,
78}
79
80impl ListView {
81 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 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 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 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(®ex::escape(&self.filter))
153 .case_insensitive(true)
154 .build()
155 })
156 .ok()
157 }
158
159 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 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 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 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
475pub 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;