use super::View;
use super::button::Button;
use super::dialog::Dialog;
use super::input_line::InputLine;
use super::label::Label;
use super::listbox::ListBox;
use crate::core::command::{CM_CANCEL, CM_FILE_FOCUSED, CM_OK, CommandId};
use crate::core::event::{Event, EventType};
use crate::core::geometry::Rect;
use crate::terminal::Terminal;
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
use std::rc::Rc;
const CMD_FILE_SELECTED: u16 = 1000;
#[derive(Debug, PartialEq, Eq)]
enum SelectionAction {
NavigateParent,
Navigate(PathBuf),
Refilter {
dir: Option<PathBuf>,
wildcard: String,
},
SelectFile(PathBuf),
}
fn classify_selection(current: &std::path::Path, file_name: &str) -> SelectionAction {
if file_name == ".." {
return SelectionAction::NavigateParent;
}
if file_name.starts_with('[') && file_name.ends_with(']') && file_name.len() >= 2 {
return SelectionAction::Navigate(current.join(&file_name[1..file_name.len() - 1]));
}
let (dir_part, file_part) = match file_name.rfind('/') {
Some(pos) => (&file_name[..=pos], &file_name[pos + 1..]),
None => ("", file_name),
};
let base = if dir_part.is_empty() {
current.to_path_buf()
} else {
current.join(dir_part)
};
if file_part.contains('*') || file_part.contains('?') {
return SelectionAction::Refilter {
dir: if dir_part.is_empty() {
None
} else {
Some(base)
},
wildcard: file_part.to_string(),
};
}
let full = if file_part.is_empty() {
base
} else {
base.join(file_part)
};
if full.is_dir() {
return SelectionAction::Navigate(full);
}
SelectionAction::SelectFile(full)
}
fn glob_match(pattern: &str, name: &str) -> bool {
fn matches(p: &[char], n: &[char]) -> bool {
match p.first() {
None => n.is_empty(),
Some('*') => matches(&p[1..], n) || (!n.is_empty() && matches(p, &n[1..])),
Some('?') => !n.is_empty() && matches(&p[1..], &n[1..]),
Some(&c) => n.first() == Some(&c) && matches(&p[1..], &n[1..]),
}
}
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
matches(&p, &n)
}
const CHILD_LISTBOX: usize = 4; const CHILD_OK_BUTTON: usize = 5;
pub struct FileDialog {
dialog: Dialog,
current_path: PathBuf,
wildcard: String,
file_name_data: Rc<RefCell<String>>,
files: Vec<String>,
selected_file_index: usize, title: String, button_label: String, }
impl FileDialog {
pub fn new(bounds: Rect, title: &str, wildcard: &str, initial_dir: Option<PathBuf>) -> Self {
let mut dialog = Dialog::new(bounds, title);
dialog.set_resizable(true);
let current_path = initial_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let file_name_data = Rc::new(RefCell::new(String::new()));
Self {
dialog,
current_path,
wildcard: wildcard.to_string(),
file_name_data,
files: Vec::new(),
selected_file_index: 0,
title: title.to_string(),
button_label: "~O~pen".to_string(), }
}
pub fn with_button_label(mut self, label: &str) -> Self {
self.button_label = label.to_string();
self
}
pub fn build(mut self) -> Self {
let bounds = self.dialog.bounds();
let dialog_width = bounds.width();
let content_width = dialog_width - 15;
let name_label = Label::new(Rect::new(2, 1, 12, 1), "~N~ame:");
self.dialog.add(Box::new(name_label));
let file_input = InputLine::new(
Rect::new(12, 1, content_width, 2),
255,
self.file_name_data.clone(),
);
self.dialog.add(Box::new(file_input));
let path_str = format!(" {}", self.current_path.display());
let path_label = Label::new(Rect::new(2, 3, content_width, 3), &path_str);
self.dialog.add(Box::new(path_label));
let files_label = Label::new(Rect::new(2, 5, 12, 5), "~F~iles:");
self.dialog.add(Box::new(files_label));
let mut file_list = ListBox::new(
Rect::new(2, 6, content_width, bounds.height() - 2),
CMD_FILE_SELECTED,
);
self.read_directory();
file_list.set_items(self.files.clone());
self.dialog.add(Box::new(file_list));
if let Some(first_item) = self.files.first() {
let display_text = if first_item.starts_with('[') && first_item.ends_with(']') {
let dir_name = &first_item[1..first_item.len() - 1];
format!("{}/{}", dir_name, self.wildcard)
} else {
first_item.clone()
};
*self.file_name_data.borrow_mut() = display_text;
}
let button_x = dialog_width - 14; let mut button_y = 6;
let button_text = format!(" {} ", self.button_label);
let open_button = Button::new(
Rect::new(button_x, button_y, button_x + 11, button_y + 2),
&button_text,
CM_OK,
true,
);
self.dialog.add(Box::new(open_button));
button_y += 3;
let cancel_button = Button::new(
Rect::new(button_x, button_y, button_x + 11, button_y + 2),
" ~C~ancel ",
CM_CANCEL,
false,
);
self.dialog.add(Box::new(cancel_button));
self.dialog.set_initial_focus();
self
}
pub fn execute(&mut self, app: &mut crate::app::Application) -> Option<PathBuf> {
use crate::core::state::SF_MODAL;
let old_state = self.dialog.state();
self.dialog.set_state(old_state | SF_MODAL);
loop {
self.update_ok_button_state();
app.desktop.draw(&mut app.terminal);
if let Some(ref mut menu_bar) = app.menu_bar {
menu_bar.draw(&mut app.terminal);
}
if let Some(ref mut status_line) = app.status_line {
status_line.draw(&mut app.terminal);
}
self.dialog.draw(&mut app.terminal);
for widget in &mut app.overlay_widgets {
widget.draw(&mut app.terminal);
}
self.dialog.update_cursor(&mut app.terminal);
let _ = app.terminal.flush();
match app
.terminal
.poll_event(std::time::Duration::from_millis(20))
.ok()
.flatten()
{
Some(mut event) => {
if event.what == EventType::Keyboard
&& event.key_code == crate::core::event::KB_ESC_ESC
{
return None;
}
self.dialog.handle_event(&mut event);
let end_state = self.dialog.get_end_state();
if end_state == crate::core::command::CM_CANCEL
|| end_state == crate::core::command::CM_CLOSE
{
return None;
}
self.sync_inputline_with_listbox();
let effective_command = if event.what == EventType::Command {
Some(event.command)
} else if end_state != 0 {
Some(end_state)
} else {
None
};
if let Some(cmd) = effective_command {
match cmd {
CM_OK => {
let file_name = self.file_name_data.borrow().clone();
if !file_name.is_empty() {
if self.contains_wildcards(&file_name) {
self.wildcard = file_name.clone();
self.read_directory();
if CHILD_LISTBOX < self.dialog.child_count() {
let view = self.dialog.child_at_mut(CHILD_LISTBOX);
if let Some(listbox) =
view.as_any_mut().downcast_mut::<ListBox>()
{
listbox.set_items(self.files.clone());
listbox.set_list_selection(0);
}
}
*self.file_name_data.borrow_mut() = self.wildcard.clone();
self.selected_file_index = 0;
self.dialog.set_end_state(0);
app.terminal.force_full_redraw();
continue;
}
if let Some(path) =
self.handle_selection(&file_name, &mut app.terminal)
{
return Some(path);
}
self.dialog.set_end_state(0);
} else {
self.dialog.set_end_state(0);
}
}
CM_CANCEL | crate::core::command::CM_CLOSE => {
return None;
}
CMD_FILE_SELECTED => {
let file_name = self.file_name_data.borrow().clone();
if !file_name.is_empty() {
if let Some(path) =
self.handle_selection(&file_name, &mut app.terminal)
{
return Some(path);
}
}
}
_ => {}
}
}
}
None => {
app.idle();
}
}
}
}
fn sync_inputline_with_listbox(&mut self) {
if CHILD_LISTBOX >= self.dialog.child_count() {
return;
}
let listbox = self.dialog.child_at(CHILD_LISTBOX);
let new_selection = listbox.get_list_selection();
if new_selection != self.selected_file_index {
self.selected_file_index = new_selection;
if self.selected_file_index < self.files.len() {
let selected = self.files[self.selected_file_index].clone();
let display_text = if selected.starts_with('[') && selected.ends_with(']') {
let dir_name = &selected[1..selected.len() - 1];
format!("{}/{}", dir_name, self.wildcard)
} else if selected == ".." {
selected.clone()
} else {
selected.clone()
};
*self.file_name_data.borrow_mut() = display_text;
let mut broadcast = Event::broadcast(CM_FILE_FOCUSED);
self.dialog.handle_event(&mut broadcast);
}
}
}
fn handle_selection(&mut self, file_name: &str, terminal: &mut Terminal) -> Option<PathBuf> {
match classify_selection(&self.current_path, file_name) {
SelectionAction::NavigateParent => {
if let Some(parent) = self.current_path.parent() {
self.current_path = parent.to_path_buf();
self.rebuild_and_redraw(terminal);
}
None
}
SelectionAction::Navigate(dir) => {
self.current_path = dir;
self.rebuild_and_redraw(terminal);
None
}
SelectionAction::Refilter { dir, wildcard } => {
if let Some(dir) = dir {
self.current_path = dir;
}
self.wildcard = wildcard;
self.rebuild_and_redraw(terminal);
None
}
SelectionAction::SelectFile(path) => {
if let Some(parent) = path.parent() {
self.current_path = parent.to_path_buf();
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| file_name.to_string());
*self.file_name_data.borrow_mut() = name;
Some(path)
}
}
}
fn update_ok_button_state(&mut self) {
use crate::core::state::SF_DISABLED;
let file_name = self.file_name_data.borrow().clone();
let should_disable = file_name.is_empty();
if CHILD_OK_BUTTON < self.dialog.child_count() {
let ok_button = self.dialog.child_at_mut(CHILD_OK_BUTTON);
ok_button.set_state_flag(SF_DISABLED, should_disable);
}
}
fn rebuild_and_redraw(&mut self, _terminal: &mut Terminal) {
let old_bounds = self.dialog.bounds();
let old_title = self.title.clone();
let old_button_label = self.button_label.clone();
*self = Self::new(
old_bounds,
&old_title,
&self.wildcard.clone(),
Some(self.current_path.clone()),
)
.with_button_label(&old_button_label)
.build();
if CHILD_LISTBOX < self.dialog.child_count() {
self.dialog.set_focus_to_child(CHILD_LISTBOX);
self.dialog
.child_at_mut(CHILD_LISTBOX)
.set_list_selection(0);
}
self.selected_file_index = 0;
if !self.files.is_empty() {
let first_item = self.files[0].clone();
let display_text = if first_item.starts_with('[') && first_item.ends_with(']') {
let dir_name = &first_item[1..first_item.len() - 1];
format!("{}/{}", dir_name, self.wildcard)
} else if first_item == ".." {
first_item.clone()
} else {
if self.wildcard.contains('*') || self.wildcard.contains('?') {
self.wildcard.clone()
} else {
first_item.clone()
}
};
*self.file_name_data.borrow_mut() = display_text;
let mut broadcast = Event::broadcast(CM_FILE_FOCUSED);
self.dialog.handle_event(&mut broadcast);
} else {
if self.wildcard.contains('*') || self.wildcard.contains('?') {
*self.file_name_data.borrow_mut() = self.wildcard.clone();
} else {
*self.file_name_data.borrow_mut() = String::new();
}
}
}
fn read_directory(&mut self) {
self.files.clear();
if self.current_path.parent().is_some() {
self.files.push("..".to_string());
}
if let Ok(entries) = fs::read_dir(&self.current_path) {
let mut dirs = Vec::new();
let mut regular_files = Vec::new();
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
let name = entry.file_name().to_string_lossy().to_string();
if metadata.is_dir() {
dirs.push(format!("[{}]", name));
} else if self.matches_wildcard(&name) {
regular_files.push(name);
}
}
}
dirs.sort();
regular_files.sort();
self.files.extend(dirs);
self.files.extend(regular_files);
}
}
fn contains_wildcards(&self, name: &str) -> bool {
name.contains('*') || name.contains('?')
}
fn matches_wildcard(&self, name: &str) -> bool {
if self.wildcard == "*.*" || self.wildcard == "*" || self.wildcard.is_empty() {
return true;
}
glob_match(&self.wildcard, name)
}
pub fn get_selected_file(&self) -> Option<PathBuf> {
let file_name = self.file_name_data.borrow().clone();
if !file_name.is_empty() {
Some(self.current_path.join(file_name))
} else {
None
}
}
pub fn get_current_directory(&self) -> PathBuf {
self.current_path.clone()
}
pub fn get_end_state(&self) -> CommandId {
self.dialog.get_end_state()
}
}
impl View for FileDialog {
fn bounds(&self) -> Rect {
self.dialog.bounds()
}
fn set_bounds(&mut self, bounds: Rect) {
self.dialog.set_bounds(bounds);
}
fn draw(&mut self, terminal: &mut Terminal) {
self.dialog.draw(terminal);
}
fn handle_event(&mut self, event: &mut Event) {
self.dialog.handle_event(event);
}
fn get_palette(&self) -> Option<crate::core::palette::Palette> {
self.dialog.get_palette()
}
}
pub struct FileDialogBuilder {
bounds: Option<Rect>,
title: Option<String>,
wildcard: String,
initial_dir: Option<PathBuf>,
button_label: String,
resizable: bool,
}
impl FileDialogBuilder {
pub fn new() -> Self {
Self {
bounds: None,
title: None,
wildcard: "*".to_string(),
initial_dir: None,
button_label: "~O~pen".to_string(),
resizable: true, }
}
#[must_use]
pub fn bounds(mut self, bounds: Rect) -> Self {
self.bounds = Some(bounds);
self
}
#[must_use]
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn wildcard(mut self, wildcard: impl Into<String>) -> Self {
self.wildcard = wildcard.into();
self
}
#[must_use]
pub fn initial_dir(mut self, dir: PathBuf) -> Self {
self.initial_dir = Some(dir);
self
}
#[must_use]
pub fn button_label(mut self, label: impl Into<String>) -> Self {
self.button_label = label.into();
self
}
#[must_use]
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
pub fn build(self) -> FileDialog {
let bounds = self.bounds.expect("FileDialog bounds must be set");
let title = self.title.expect("FileDialog title must be set");
let mut fd = FileDialog::new(bounds, &title, &self.wildcard, self.initial_dir)
.with_button_label(&self.button_label)
.build();
fd.dialog.set_resizable(self.resizable);
fd
}
pub fn build_boxed(self) -> Box<FileDialog> {
Box::new(self.build())
}
}
impl Default for FileDialogBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn glob_star_matches_prefix_not_substring() {
assert!(glob_match("foo*", "foo.txt"));
assert!(glob_match("foo*", "foo"));
assert!(!glob_match("foo*", "xfoo"));
assert!(!glob_match("foo*", "xfoo.txt"));
}
#[test]
fn glob_question_mark_matches_single_char() {
assert!(glob_match("a?c.txt", "abc.txt"));
assert!(glob_match("a?c.txt", "axc.txt"));
assert!(!glob_match("a?c.txt", "ac.txt"));
assert!(!glob_match("a?c.txt", "abbc.txt"));
}
#[test]
fn glob_star_anywhere() {
assert!(glob_match("*.rs", "main.rs"));
assert!(!glob_match("*.rs", "main.rss"));
assert!(glob_match("a*b*c", "aXXbYYc"));
assert!(!glob_match("a*b*c", "aXXbYY"));
assert!(!glob_match("*.RS", "main.rs"));
}
#[test]
fn bare_name_is_exact_match_not_substring() {
let dialog = FileDialog::new(Rect::new(0, 0, 60, 20), "t", "test", None);
assert!(dialog.matches_wildcard("test"));
assert!(!dialog.matches_wildcard("my_test.rs"));
assert!(!dialog.matches_wildcard("testing"));
}
#[test]
fn star_dot_star_matches_everything() {
let dialog = FileDialog::new(Rect::new(0, 0, 60, 20), "t", "*.*", None);
assert!(dialog.matches_wildcard("main.rs"));
assert!(dialog.matches_wildcard("Makefile"));
}
#[test]
fn classify_parent_and_bracket_dirs() {
let cur = Path::new("/base");
assert_eq!(
classify_selection(cur, ".."),
SelectionAction::NavigateParent
);
assert_eq!(
classify_selection(cur, "[src]"),
SelectionAction::Navigate(PathBuf::from("/base/src"))
);
}
#[test]
fn classify_wildcard_refilters() {
let cur = Path::new("/base");
assert_eq!(
classify_selection(cur, "*.rs"),
SelectionAction::Refilter {
dir: None,
wildcard: "*.rs".to_string()
}
);
assert_eq!(
classify_selection(cur, "src/*.txt"),
SelectionAction::Refilter {
dir: Some(PathBuf::from("/base/src/")),
wildcard: "*.txt".to_string()
}
);
}
#[test]
fn classify_existing_directory_navigates() {
let tmp = std::env::temp_dir();
let sub = tmp.join("tv_fd_test_dir");
std::fs::create_dir_all(&sub).unwrap();
let action = classify_selection(&tmp, "tv_fd_test_dir");
assert_eq!(action, SelectionAction::Navigate(sub.clone()));
let _ = std::fs::remove_dir(&sub);
}
#[test]
fn classify_typed_path_returns_file_not_navigation() {
let cur = Path::new("/base");
assert_eq!(
classify_selection(cur, "src/main.rs"),
SelectionAction::SelectFile(PathBuf::from("/base/src/main.rs"))
);
assert_eq!(
classify_selection(cur, "/etc/hosts.bak"),
SelectionAction::SelectFile(PathBuf::from("/etc/hosts.bak"))
);
}
#[test]
fn handle_selection_typed_path_returns_joined_path() {
let tmp = std::env::temp_dir().join("tv_fd_test_sel");
let sub = tmp.join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("file.txt"), b"x").unwrap();
struct NullBackend;
impl crate::terminal::Backend for NullBackend {
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn init(&mut self) -> std::io::Result<()> {
Ok(())
}
fn cleanup(&mut self) -> std::io::Result<()> {
Ok(())
}
fn size(&self) -> std::io::Result<(u16, u16)> {
Ok((80, 25))
}
fn poll_event(
&mut self,
_timeout: std::time::Duration,
) -> std::io::Result<Option<Event>> {
Ok(None)
}
fn write_raw(&mut self, _data: &[u8]) -> std::io::Result<()> {
Ok(())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
fn show_cursor(&mut self, _x: u16, _y: u16) -> std::io::Result<()> {
Ok(())
}
fn hide_cursor(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut dialog =
FileDialog::new(Rect::new(0, 0, 60, 20), "t", "*", Some(tmp.clone())).build();
let mut terminal = Terminal::with_backend(Box::new(NullBackend)).unwrap();
let result = dialog.handle_selection("sub/file.txt", &mut terminal);
assert_eq!(result, Some(sub.join("file.txt")));
assert_eq!(dialog.get_current_directory(), sub);
assert_eq!(*dialog.file_name_data.borrow(), "file.txt");
let _ = std::fs::remove_dir_all(&tmp);
}
}