1use 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#[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 path: Option<PathBuf>,
31 is_dir: bool,
32 is_use_here: bool,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum PathPickerEvent {
38 None,
40 Confirmed(PathBuf),
42}
43
44#[derive(Debug, Clone)]
45pub struct PathPicker {
46 mode: PathPickerMode,
47 pub focus: PathPickerFocus,
48 pub path_text: String,
50 pub path_cursor: usize,
51 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 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 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 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 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 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; 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
495fn 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
554pub 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}