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