use std::cell::Cell;
use std::fs;
use std::path::{Path, PathBuf};
use crossterm::event::{KeyCode, KeyEvent};
use ratada::input::InputField;
use ratada::text::truncate;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use crate::theme::Skin;
use crate::tui::list_layout::moved_cursor;
use crate::tui::presentation::{FieldView, field_spans};
use crate::tui::skin::Colors;
use crate::tui::widgets::centered_rect;
const FILTER_LABEL: &str = "filter: ";
pub enum PickerResult {
Pending,
Selected(PathBuf),
Cancel,
}
struct Entry {
path: PathBuf,
name: String,
is_dir: bool,
}
pub struct PathPicker {
current_dir: PathBuf,
allow_files: bool,
show_hidden: bool,
entries: Vec<Entry>,
visible: Vec<usize>,
filter: InputField,
cursor: usize,
offset: Cell<usize>,
}
impl PathPicker {
pub fn new(start: &Path, allow_files: bool) -> Self {
let current_dir = start_dir(start);
let mut picker = PathPicker {
current_dir,
allow_files,
show_hidden: false,
entries: Vec::new(),
visible: Vec::new(),
filter: InputField::new(""),
cursor: 0,
offset: Cell::new(0),
};
picker.reload();
picker
}
pub fn handle_key(&mut self, key: KeyEvent) -> PickerResult {
match key.code {
KeyCode::Esc => return PickerResult::Cancel,
_ if self.navigate(key) => {}
KeyCode::Right => self.descend(),
KeyCode::Left => self.ascend(),
KeyCode::Backspace if self.filter.value().is_empty() => {
self.ascend();
}
KeyCode::Char('h') if ratada::input::is_command(key) => {
self.toggle_hidden();
}
KeyCode::Enter => return self.choose(),
_ => {
if self.filter.handle_key(key) {
self.apply_filter();
}
}
}
PickerResult::Pending
}
pub fn paste(&mut self, text: &str) {
self.filter.paste(text);
self.apply_filter();
}
pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let colors = Colors::from_palette(&skin.palette);
let rect =
centered_rect(70, area.height.saturating_sub(4).max(8), area);
frame.render_widget(Clear, rect);
let block = ratada::chrome::modal_block(skin, "Pick path");
let inner = block.inner(rect);
frame.render_widget(block, rect);
let badge =
ratada::chrome::position_badge(self.cursor, self.visible.len());
ratada::chrome::render_badge(frame, rect, skin, &badge);
let width = inner.width as usize;
let footer = ratada::shortcut_hints::lines(
&[
("\u{2190}\u{2192}", "browse"),
("enter", "pick"),
("^H", "hidden"),
],
skin.palette.accent_dim,
width,
);
let footer_h = (footer.len() as u16).max(1);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(footer_h),
])
.split(inner);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
truncate(&self.current_dir.to_string_lossy(), width),
Style::default().fg(colors.dim),
))),
rows[0],
);
self.render_filter(frame, rows[1], skin);
self.render_list(frame, rows[2], skin);
frame.render_widget(Paragraph::new(footer), rows[3]);
}
fn render_filter(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let colors = Colors::from_palette(&skin.palette);
let mut spans =
vec![Span::styled(FILTER_LABEL, Style::default().fg(colors.dim))];
spans.extend(field_spans(FieldView {
field: &self.filter,
palette: &skin.palette,
width: (area.width as usize)
.saturating_sub(FILTER_LABEL.chars().count()),
focused: true,
}));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_list(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let accent = Colors::from_palette(&skin.palette).accent;
let width = area.width as usize;
let rows: Vec<Line<'static>> = self
.visible
.iter()
.map(|&index| {
let entry = &self.entries[index];
let marker = if entry.is_dir { "/" } else { " " };
let line = Line::from(truncate(
&format!("{marker} {}", entry.name),
width,
));
if entry.is_dir {
line.style(Style::default().fg(accent))
} else {
line
}
})
.collect();
ratada::list::render(
frame,
area,
skin,
ratada::list::ListView {
rows,
selected: self.cursor,
offset: &self.offset,
},
);
}
fn navigate(&mut self, key: KeyEvent) -> bool {
match moved_cursor(key, self.cursor, self.visible.len()) {
Some(cursor) => {
self.cursor = cursor;
true
}
None => false,
}
}
fn descend(&mut self) {
if let Some(entry) = self.selected_entry()
&& entry.is_dir
{
self.current_dir = entry.path.clone();
self.filter = InputField::new("");
self.reload();
}
}
fn ascend(&mut self) {
if let Some(parent) = self.current_dir.parent() {
self.current_dir = parent.to_path_buf();
self.filter = InputField::new("");
self.reload();
}
}
fn choose(&mut self) -> PickerResult {
match self.selected_entry() {
Some(entry) if entry.is_dir || self.allow_files => {
PickerResult::Selected(entry.path.clone())
}
_ => PickerResult::Pending,
}
}
fn selected_entry(&self) -> Option<&Entry> {
self.visible
.get(self.cursor)
.map(|&index| &self.entries[index])
}
fn reload(&mut self) {
self.entries = read_children(
&self.current_dir,
self.allow_files,
self.show_hidden,
);
self.cursor = 0;
self.offset.set(0);
self.apply_filter();
}
fn toggle_hidden(&mut self) {
self.show_hidden = !self.show_hidden;
self.entries = read_children(
&self.current_dir,
self.allow_files,
self.show_hidden,
);
self.offset.set(0);
self.apply_filter();
}
fn apply_filter(&mut self) {
let needle = self.filter.value().to_lowercase();
self.visible = self
.entries
.iter()
.enumerate()
.filter(|(_, entry)| {
needle.is_empty() || entry.name.to_lowercase().contains(&needle)
})
.map(|(index, _)| index)
.collect();
if self.cursor >= self.visible.len() {
self.cursor = self.visible.len().saturating_sub(1);
}
}
}
fn start_dir(start: &Path) -> PathBuf {
if start.is_dir() {
return start.to_path_buf();
}
start
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("/"))
}
fn read_children(
dir: &Path,
allow_files: bool,
show_hidden: bool,
) -> Vec<Entry> {
let Ok(read_dir) = fs::read_dir(dir) else {
return Vec::new();
};
let mut dirs: Vec<Entry> = Vec::new();
let mut files: Vec<Entry> = Vec::new();
for entry in read_dir.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !show_hidden && is_hidden(&name) {
continue;
}
if path.is_dir() {
dirs.push(Entry {
path,
name,
is_dir: true,
});
} else if allow_files {
files.push(Entry {
path,
name,
is_dir: false,
});
}
}
dirs.sort_by_key(|entry| entry.name.to_lowercase());
files.sort_by_key(|entry| entry.name.to_lowercase());
dirs.into_iter().chain(files).collect()
}
fn is_hidden(name: &str) -> bool {
name.starts_with('.')
}
#[cfg(test)]
mod tests {
use std::fs;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::{PathPicker, is_hidden};
fn picker_over_a_temp_dir() -> (PathPicker, std::path::PathBuf) {
let dir = std::env::temp_dir()
.join(format!("hop-path-picker-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("visible")).expect("temp dir is writable");
fs::create_dir_all(dir.join(".hidden")).expect("temp dir is writable");
(PathPicker::new(&dir, false), dir)
}
#[test]
fn altgr_h_types_into_the_filter_instead_of_toggling_hidden() {
let (mut picker, dir) = picker_over_a_temp_dir();
assert!(!picker.show_hidden);
picker.handle_key(KeyEvent::new(
KeyCode::Char('h'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
));
assert!(!picker.show_hidden, "AltGr+H toggled the hidden entries");
assert_eq!(picker.filter.value(), "h", "AltGr+H did not type");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn ctrl_h_toggles_the_hidden_entries() {
let (mut picker, dir) = picker_over_a_temp_dir();
let ctrl_h = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL);
picker.handle_key(ctrl_h);
assert!(picker.show_hidden);
assert!(picker.filter.value().is_empty(), "Ctrl+H must not type");
picker.handle_key(ctrl_h);
assert!(!picker.show_hidden);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn dot_prefixed_names_are_hidden() {
assert!(is_hidden(".git"));
assert!(is_hidden(".config"));
assert!(!is_hidden("src"));
assert!(!is_hidden("Cargo.toml"));
}
}