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