Skip to main content

romm_cli/tui/
path_picker.rs

1//! Shared filesystem path browser for TUI (directory or file pick).
2
3use std::path::{Path, PathBuf};
4
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6use ratatui::layout::{Constraint, Direction, Layout, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
9use ratatui::Frame;
10
11/// Whether the user must pick a directory or a regular file.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum PathPickerMode {
14    Directory,
15    File,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum PathPickerFocus {
20    PathBar,
21    List,
22}
23
24#[derive(Debug, Clone)]
25struct ListEntry {
26    label: String,
27    /// `None` means the synthetic "use this folder" row (directory mode only).
28    path: Option<PathBuf>,
29    is_dir: bool,
30    is_use_here: bool,
31}
32
33/// Result of handling a key press.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum PathPickerEvent {
36    /// No completion; keep browsing.
37    None,
38    /// User chose a path (caller should validate with app rules if needed).
39    Confirmed(PathBuf),
40}
41
42#[derive(Debug, Clone)]
43pub struct PathPicker {
44    mode: PathPickerMode,
45    pub focus: PathPickerFocus,
46    /// Editable path string (may be relative or absolute).
47    pub path_text: String,
48    pub path_cursor: usize,
49    /// Directory whose contents are listed.
50    browse_dir: PathBuf,
51    entries: Vec<ListEntry>,
52    list_state: ListState,
53    io_error: Option<String>,
54}
55
56impl PathPicker {
57    pub fn new(mode: PathPickerMode, initial_path: &str) -> Self {
58        let path_text = initial_path.to_string();
59        let path_cursor = path_text.len();
60        let browse_dir = resolve_browse_directory(&path_text);
61        let mut s = Self {
62            mode,
63            focus: PathPickerFocus::PathBar,
64            path_text,
65            path_cursor,
66            browse_dir,
67            entries: Vec::new(),
68            list_state: ListState::default(),
69            io_error: None,
70        };
71        s.refresh_entries();
72        s
73    }
74
75    /// Current path string shown in the bar (trimmed for persistence).
76    pub fn path_trimmed(&self) -> String {
77        self.path_text.trim().to_string()
78    }
79
80    pub fn set_path_text(&mut self, s: String) {
81        self.path_text = s;
82        self.path_cursor = self.path_text.len();
83        self.sync_browse_from_path_text();
84        self.refresh_entries();
85    }
86
87    fn sync_browse_from_path_text(&mut self) {
88        self.browse_dir = resolve_browse_directory(&self.path_text);
89        self.io_error = None;
90    }
91
92    fn refresh_entries(&mut self) {
93        self.entries.clear();
94        let name_filter = entry_name_prefix_filter(&self.path_text, &self.browse_dir);
95        if self.mode == PathPickerMode::Directory {
96            self.entries.push(ListEntry {
97                label: "< Use this folder >".to_string(),
98                path: None,
99                is_dir: true,
100                is_use_here: true,
101            });
102        }
103        match std::fs::read_dir(&self.browse_dir) {
104            Ok(rd) => {
105                self.io_error = None;
106                let mut dirs: Vec<PathBuf> = Vec::new();
107                let mut files: Vec<PathBuf> = Vec::new();
108                for e in rd.flatten() {
109                    let p = e.path();
110                    let Ok(ft) = e.file_type() else {
111                        continue;
112                    };
113                    let fname = file_name_display(&p);
114                    if let Some(ref pref) = name_filter {
115                        if !fname.to_lowercase().starts_with(pref) {
116                            continue;
117                        }
118                    }
119                    if ft.is_dir() {
120                        dirs.push(p);
121                    } else if ft.is_file() {
122                        files.push(p);
123                    }
124                }
125                dirs.sort_by(|a, b| cmp_path_names(a, b));
126                files.sort_by(|a, b| cmp_path_names(a, b));
127                if self.browse_dir.parent().is_some() {
128                    self.entries.push(ListEntry {
129                        label: "..".to_string(),
130                        path: Some(self.browse_dir.parent().unwrap().to_path_buf()),
131                        is_dir: true,
132                        is_use_here: false,
133                    });
134                }
135                for p in dirs {
136                    let name = file_name_display(&p);
137                    self.entries.push(ListEntry {
138                        label: format!("[{name}]"),
139                        path: Some(p),
140                        is_dir: true,
141                        is_use_here: false,
142                    });
143                }
144                if self.mode == PathPickerMode::File {
145                    for p in files {
146                        let name = file_name_display(&p);
147                        self.entries.push(ListEntry {
148                            label: name,
149                            path: Some(p),
150                            is_dir: false,
151                            is_use_here: false,
152                        });
153                    }
154                } else {
155                    for p in files {
156                        let name = file_name_display(&p);
157                        self.entries.push(ListEntry {
158                            label: format!("{name}  (file)"),
159                            path: Some(p),
160                            is_dir: false,
161                            is_use_here: false,
162                        });
163                    }
164                }
165            }
166            Err(e) => {
167                self.io_error = Some(format!("{}", e));
168            }
169        }
170        let n = self.entries.len();
171        let sel = self
172            .list_state
173            .selected()
174            .unwrap_or(0)
175            .min(n.saturating_sub(1));
176        self.list_state.select(Some(sel));
177    }
178
179    fn confirm_browse_dir(&self) -> PathPickerEvent {
180        PathPickerEvent::Confirmed(self.browse_dir.clone())
181    }
182
183    /// Directory mode: confirm the path typed in the bar (may not exist yet; caller runs
184    /// `create_dir_all` via validation). Empty bar falls back to the listing directory.
185    fn confirm_typed_directory_path(&self) -> PathPickerEvent {
186        let t = self.path_text.trim();
187        if t.is_empty() {
188            return self.confirm_browse_dir();
189        }
190        PathPickerEvent::Confirmed(resolve_path_for_confirm(t))
191    }
192
193    fn try_confirm_path_text(&self) -> Option<PathPickerEvent> {
194        let t = self.path_text.trim();
195        if t.is_empty() {
196            return None;
197        }
198        let p = resolve_path_for_confirm(t);
199        match self.mode {
200            PathPickerMode::Directory => {
201                if p.exists() && !p.is_dir() {
202                    return None;
203                }
204                Some(PathPickerEvent::Confirmed(p))
205            }
206            PathPickerMode::File => {
207                let meta = std::fs::metadata(&p).ok()?;
208                if meta.is_file() {
209                    Some(PathPickerEvent::Confirmed(p))
210                } else {
211                    None
212                }
213            }
214        }
215    }
216
217    fn enter_list_selection(&mut self) -> PathPickerEvent {
218        let idx = self.list_state.selected().unwrap_or(0);
219        let Some(entry) = self.entries.get(idx) else {
220            return PathPickerEvent::None;
221        };
222        if entry.is_use_here {
223            return self.confirm_browse_dir();
224        }
225        let Some(ref target) = entry.path else {
226            return PathPickerEvent::None;
227        };
228        if entry.is_dir {
229            self.browse_dir = target.clone();
230            self.path_text = target.display().to_string();
231            self.path_cursor = self.path_text.len();
232            self.refresh_entries();
233            self.list_state.select(Some(0));
234            PathPickerEvent::None
235        } else if self.mode == PathPickerMode::File {
236            PathPickerEvent::Confirmed(target.clone())
237        } else {
238            PathPickerEvent::None
239        }
240    }
241
242    pub fn handle_key(&mut self, key: &KeyEvent) -> PathPickerEvent {
243        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
244
245        // Ctrl+Enter: confirm typed directory path (dir mode) or existing file path (file mode).
246        if ctrl && key.code == KeyCode::Enter {
247            if self.mode == PathPickerMode::Directory {
248                return self.confirm_typed_directory_path();
249            }
250            if let Some(ev) = self.try_confirm_path_text() {
251                return ev;
252            }
253            return PathPickerEvent::None;
254        }
255
256        match key.code {
257            KeyCode::Tab => {
258                self.focus = match self.focus {
259                    PathPickerFocus::PathBar => PathPickerFocus::List,
260                    PathPickerFocus::List => PathPickerFocus::PathBar,
261                };
262                PathPickerEvent::None
263            }
264            KeyCode::Esc => PathPickerEvent::None,
265            _ => match self.focus {
266                PathPickerFocus::PathBar => self.handle_path_bar_key(key),
267                PathPickerFocus::List => self.handle_list_key(key),
268            },
269        }
270    }
271
272    fn handle_path_bar_key(&mut self, key: &KeyEvent) -> PathPickerEvent {
273        match key.code {
274            KeyCode::Enter => {
275                if let Some(ev) = self.try_confirm_path_text() {
276                    return ev;
277                }
278                PathPickerEvent::None
279            }
280            KeyCode::Char(c) => {
281                let pos = self.path_cursor.min(self.path_text.len());
282                self.path_text.insert(pos, c);
283                self.path_cursor = pos + c.len_utf8();
284                self.sync_browse_from_path_text();
285                self.refresh_entries();
286                PathPickerEvent::None
287            }
288            KeyCode::Backspace => {
289                if self.path_cursor > 0 && self.path_cursor <= self.path_text.len() {
290                    let prev = self.path_text[..self.path_cursor]
291                        .chars()
292                        .next_back()
293                        .map(|c| c.len_utf8())
294                        .unwrap_or(1);
295                    let start = self.path_cursor - prev;
296                    self.path_text.replace_range(start..self.path_cursor, "");
297                    self.path_cursor = start;
298                    self.sync_browse_from_path_text();
299                    self.refresh_entries();
300                }
301                PathPickerEvent::None
302            }
303            KeyCode::Left => {
304                if self.path_cursor > 0 {
305                    let prev = self.path_text[..self.path_cursor]
306                        .chars()
307                        .next_back()
308                        .map(|c| c.len_utf8())
309                        .unwrap_or(1);
310                    self.path_cursor -= prev;
311                }
312                PathPickerEvent::None
313            }
314            KeyCode::Right => {
315                if self.path_cursor < self.path_text.len() {
316                    let next = self.path_text[self.path_cursor..]
317                        .chars()
318                        .next()
319                        .map(|c| c.len_utf8())
320                        .unwrap_or(1);
321                    self.path_cursor += next;
322                }
323                PathPickerEvent::None
324            }
325            KeyCode::Up | KeyCode::Down => {
326                // Move focus to the list without consuming the arrow as a list move — otherwise
327                // PathBar+Up lands on row 0 and immediately "moves up" nowhere.
328                self.focus = PathPickerFocus::List;
329                PathPickerEvent::None
330            }
331            _ => PathPickerEvent::None,
332        }
333    }
334
335    fn handle_list_key(&mut self, key: &KeyEvent) -> PathPickerEvent {
336        let n = self.entries.len();
337        if n == 0 {
338            return PathPickerEvent::None;
339        }
340        let sel = self.list_state.selected().unwrap_or(0);
341        match key.code {
342            KeyCode::Enter => self.enter_list_selection(),
343            KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => {
344                if sel == 0 {
345                    self.focus = PathPickerFocus::PathBar;
346                } else {
347                    self.list_state.select(Some(sel - 1));
348                }
349                PathPickerEvent::None
350            }
351            KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => {
352                let i = (sel + 1).min(n - 1);
353                self.list_state.select(Some(i));
354                PathPickerEvent::None
355            }
356            KeyCode::Home => {
357                self.list_state.select(Some(0));
358                PathPickerEvent::None
359            }
360            KeyCode::End => {
361                self.list_state.select(Some(n - 1));
362                PathPickerEvent::None
363            }
364            KeyCode::Left | KeyCode::Char('h') => {
365                self.focus = PathPickerFocus::PathBar;
366                PathPickerEvent::None
367            }
368            KeyCode::Right | KeyCode::Char('l') => {
369                self.focus = PathPickerFocus::PathBar;
370                PathPickerEvent::None
371            }
372            KeyCode::Char(c) if !matches!(c, 'j' | 'k' | 'h' | 'l' | 'J' | 'K' | 'H' | 'L') => {
373                self.focus = PathPickerFocus::PathBar;
374                self.handle_path_bar_key(key)
375            }
376            _ => PathPickerEvent::None,
377        }
378    }
379
380    pub fn render(&mut self, f: &mut Frame, area: Rect, title: &str, footer_hint: &str) {
381        let block = Block::default().title(title).borders(Borders::ALL);
382        let inner = block.inner(area);
383        f.render_widget(block, area);
384
385        let path_h = if self.io_error.is_some() { 2u16 } else { 1u16 };
386        let chunks = Layout::default()
387            .direction(Direction::Vertical)
388            .constraints([
389                Constraint::Length(path_h),
390                Constraint::Min(3),
391                Constraint::Length(2),
392            ])
393            .split(inner);
394
395        let path_prefix = match self.focus {
396            PathPickerFocus::PathBar => "▶ ",
397            PathPickerFocus::List => "  ",
398        };
399        let before: String = self.path_text.chars().take(self.path_cursor).collect();
400        let after: String = self.path_text.chars().skip(self.path_cursor).collect();
401        let path_style = if self.focus == PathPickerFocus::PathBar {
402            Style::default().fg(Color::Yellow)
403        } else {
404            Style::default()
405        };
406        let path_line = format!("{path_prefix}{before}▏{after}");
407        let path_row = Rect {
408            x: chunks[0].x,
409            y: chunks[0].y,
410            width: chunks[0].width,
411            height: 1,
412        };
413        f.render_widget(Paragraph::new(path_line).style(path_style), path_row);
414
415        if let Some(ref err) = self.io_error {
416            f.render_widget(
417                Paragraph::new(format!("⚠ {err}")).style(Style::default().fg(Color::Red)),
418                Rect {
419                    x: chunks[0].x,
420                    y: chunks[0].y + 1,
421                    width: chunks[0].width,
422                    height: 1,
423                },
424            );
425        }
426
427        let list_block = Block::default().borders(Borders::ALL).border_style(
428            if self.focus == PathPickerFocus::List {
429                Style::default().fg(Color::Yellow)
430            } else {
431                Style::default()
432            },
433        );
434        let list_inner = list_block.inner(chunks[1]);
435        f.render_widget(list_block, chunks[1]);
436
437        let items: Vec<ListItem> = self
438            .entries
439            .iter()
440            .map(|e| {
441                let style = if e.is_use_here {
442                    Style::default()
443                        .fg(Color::Green)
444                        .add_modifier(Modifier::BOLD)
445                } else if !e.is_dir {
446                    Style::default().fg(Color::DarkGray)
447                } else {
448                    Style::default()
449                };
450                ListItem::new(e.label.clone()).style(style)
451            })
452            .collect();
453        let list = List::new(items).highlight_style(
454            Style::default()
455                .fg(Color::Yellow)
456                .add_modifier(Modifier::BOLD),
457        );
458        f.render_stateful_widget(list, list_inner, &mut self.list_state);
459
460        f.render_widget(
461            Paragraph::new(footer_hint).style(Style::default().fg(Color::Cyan)),
462            chunks[2],
463        );
464    }
465
466    /// Terminal cursor inside path bar (relative to `area` passed to `render`).
467    pub fn cursor_position(&self, area: Rect, title: &str) -> Option<(u16, u16)> {
468        if self.focus != PathPickerFocus::PathBar {
469            return None;
470        }
471        let block = Block::default().title(title).borders(Borders::ALL);
472        let inner = block.inner(area);
473        let path_h = if self.io_error.is_some() { 2u16 } else { 1u16 };
474        let chunks = Layout::default()
475            .direction(Direction::Vertical)
476            .constraints([
477                Constraint::Length(path_h),
478                Constraint::Min(3),
479                Constraint::Length(2),
480            ])
481            .split(inner);
482        let path_prefix_chars = 2u16; // "▶ "
483        let byte_before = self.path_cursor.min(self.path_text.len());
484        let path_before: String = self.path_text.chars().take(byte_before).collect();
485        let x = chunks[0].x + path_prefix_chars + path_before.chars().count() as u16;
486        let y = chunks[0].y;
487        Some((x.min(chunks[0].x + chunks[0].width.saturating_sub(1)), y))
488    }
489}
490
491/// If the user is typing past `browse_dir` into a new path segment, return that segment's
492/// prefix so the listing can narrow to matching names (case-insensitive).
493fn entry_name_prefix_filter(path_text: &str, browse_dir: &Path) -> Option<String> {
494    let trimmed = path_text.trim();
495    if trimmed.is_empty() {
496        return None;
497    }
498    let abs = typed_absolute_path(trimmed);
499    if abs.as_os_str().is_empty() {
500        return None;
501    }
502    let rel = strip_prefix_path(&abs, browse_dir)?;
503    let mut it = rel.components();
504    let first = it.next()?;
505    let s = first.as_os_str().to_string_lossy();
506    if s.is_empty() {
507        None
508    } else {
509        Some(s.to_lowercase())
510    }
511}
512
513fn typed_absolute_path(trimmed: &str) -> PathBuf {
514    let p = PathBuf::from(trimmed);
515    if p.is_relative() {
516        std::env::current_dir()
517            .unwrap_or_else(|_| PathBuf::from("."))
518            .join(p)
519    } else {
520        p
521    }
522}
523
524fn strip_prefix_path(full: &Path, prefix: &Path) -> Option<PathBuf> {
525    let mut full_c = full.components();
526    let prefix_c: Vec<_> = prefix.components().collect();
527    if prefix_c.is_empty() {
528        return Some(PathBuf::from_iter(full_c));
529    }
530    for c in &prefix_c {
531        if full_c.next()? != *c {
532            return None;
533        }
534    }
535    Some(PathBuf::from_iter(full_c))
536}
537
538fn file_name_display(p: &Path) -> String {
539    p.file_name()
540        .map(|s| s.to_string_lossy().into_owned())
541        .unwrap_or_default()
542}
543
544fn cmp_path_names(a: &Path, b: &Path) -> std::cmp::Ordering {
545    let sa = a.file_name().and_then(|s| s.to_str()).unwrap_or("");
546    let sb = b.file_name().and_then(|s| s.to_str()).unwrap_or("");
547    sa.to_lowercase().cmp(&sb.to_lowercase())
548}
549
550/// Resolve a directory to list for the given user input.
551pub fn resolve_browse_directory(path_input: &str) -> PathBuf {
552    let trimmed = path_input.trim();
553    if trimmed.is_empty() {
554        return dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
555    }
556    let p = PathBuf::from(trimmed);
557    let p = if p.is_relative() {
558        std::env::current_dir()
559            .unwrap_or_else(|_| PathBuf::from("."))
560            .join(&p)
561    } else {
562        p
563    };
564
565    if let Ok(meta) = std::fs::metadata(&p) {
566        if meta.is_dir() {
567            return p;
568        }
569        if meta.is_file() {
570            return p.parent().map(Path::to_path_buf).unwrap_or(p);
571        }
572    }
573
574    let mut cur = p.clone();
575    loop {
576        if let Ok(m) = std::fs::metadata(&cur) {
577            if m.is_dir() {
578                return cur;
579            }
580        }
581        if let Some(parent) = cur.parent() {
582            if parent == cur {
583                break;
584            }
585            cur = parent.to_path_buf();
586        } else {
587            break;
588        }
589    }
590
591    dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
592}
593
594fn resolve_path_for_confirm(trimmed: &str) -> PathBuf {
595    let p = PathBuf::from(trimmed);
596    if p.is_relative() {
597        std::env::current_dir()
598            .unwrap_or_else(|_| PathBuf::from("."))
599            .join(p)
600    } else {
601        p
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn resolve_browse_empty_uses_home_or_dot() {
611        let r = resolve_browse_directory("");
612        assert!(r.exists() || r == std::path::Path::new("."));
613    }
614
615    #[test]
616    fn resolve_browse_existing_dir() {
617        let tmp = std::env::temp_dir();
618        let r = resolve_browse_directory(tmp.to_str().unwrap());
619        assert_eq!(r, tmp);
620    }
621
622    #[test]
623    fn resolve_browse_nonexistent_child_lists_parent() {
624        let tmp = std::env::temp_dir();
625        let bogus = tmp.join("romm_path_picker_nonexistent_child_xyz");
626        let r = resolve_browse_directory(bogus.to_str().unwrap());
627        assert_eq!(r, tmp);
628    }
629
630    #[test]
631    fn entry_name_prefix_filter_incomplete_segment() {
632        let tmp = std::env::temp_dir();
633        let browse = resolve_browse_directory(tmp.to_str().unwrap());
634        let tail = tmp.join("romm_filter_test_nonexistent_abc");
635        let typed = tail.to_string_lossy();
636        let f = entry_name_prefix_filter(&typed, &browse);
637        assert_eq!(f.as_deref(), Some("romm_filter_test_nonexistent_abc"));
638    }
639}