mod places;
mod sort;
pub( crate ) mod view;
#[ cfg( test ) ]
mod tests;
pub use places::{ home, Place, PlaceKind };
pub use sort::{ Directions, Sort, SortKey };
use std::io;
use std::path::{ Path, PathBuf };
use std::sync::Arc;
use std::time::SystemTime;
use crate::app::ChannelSender;
use crate::widget::Element;
use crate::files::{ self, Kind };
use crate::types::WidgetId;
pub const NAME: WidgetId = WidgetId( "ltk.file_chooser.name" );
pub const FOLDER_NAME: WidgetId = WidgetId( "ltk.file_chooser.folder" );
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum Purpose
{
Open,
Save,
}
#[ derive( Clone, Debug, PartialEq, Eq ) ]
pub enum Chosen
{
Path( PathBuf ),
Cancelled,
}
#[ derive( Clone, Debug ) ]
pub enum FileChooserMsg
{
Listed( PathBuf, Result<Vec<files::Entry>, String> ),
Enter( PathBuf ),
Up,
Pick( PathBuf ),
Name( String ),
Confirm,
Replace,
KeepFile,
NewFolder,
FolderName( String ),
CreateFolder,
CancelFolder,
FolderCreated( PathBuf, Result<(), String> ),
Mount,
Mounted( Result<files::Mount, Option<String>> ),
TogglePlaces,
ToggleSort,
SetSort( Sort ),
CloseNotice,
Cancel,
}
#[ derive( Clone, Debug, PartialEq ) ]
pub( crate ) struct Item
{
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub size: u64,
pub mime: Option<&'static str>,
pub modified: Option<SystemTime>,
pub accessed: Option<SystemTime>,
}
pub struct FileChooser<Msg>
{
purpose: Purpose,
dir: PathBuf,
items: Vec<Item>,
sort: Sort,
sort_open: bool,
name: String,
error: Option<String>,
loading: bool,
filters: Vec<String>,
compact: bool,
places_open: bool,
replacing: Option<PathBuf>,
new_folder: Option<String>,
notice: Option<String>,
focus: Option<WidgetId>,
sender: ChannelSender<Msg>,
wrap: Arc<dyn Fn( FileChooserMsg ) -> Msg + Send + Sync>,
}
impl<Msg: Clone + Send + 'static> FileChooser<Msg>
{
pub fn open( sender: &ChannelSender<Msg>, wrap: impl Fn( FileChooserMsg ) -> Msg + Send + Sync + 'static ) -> Self
{
Self::new( Purpose::Open, String::new(), sender, Arc::new( wrap ) )
}
pub fn save( sender: &ChannelSender<Msg>, wrap: impl Fn( FileChooserMsg ) -> Msg + Send + Sync + 'static, name: impl Into<String> ) -> Self
{
let mut chooser = Self::new( Purpose::Save, name.into(), sender, Arc::new( wrap ) );
chooser.focus = Some( NAME );
chooser
}
fn new( purpose: Purpose, name: String, sender: &ChannelSender<Msg>, wrap: Arc<dyn Fn( FileChooserMsg ) -> Msg + Send + Sync> ) -> Self
{
let mut chooser = Self
{
purpose,
dir: PathBuf::new(),
items: Vec::new(),
sort: Sort::default(),
sort_open: false,
name,
error: None,
loading: true,
filters: Vec::new(),
compact: false,
places_open: false,
replacing: None,
new_folder: None,
notice: None,
focus: None,
sender: sender.clone(),
wrap,
};
chooser.enter( home() );
chooser
}
pub fn dir( mut self, dir: impl Into<PathBuf> ) -> Self
{
self.enter( dir.into() );
self
}
pub fn filter( mut self, types: &[ &str ] ) -> Self
{
self.filters = types.iter().map( |t| t.to_string() ).collect();
let dir = self.dir.clone();
self.enter( dir );
self
}
pub fn sort( mut self, sort: Sort ) -> Self
{
self.set_sort( sort );
self
}
pub fn current_sort( &self ) -> Sort
{
self.sort
}
pub fn compact( mut self, on: bool ) -> Self
{
self.compact = on;
self
}
pub fn purpose( &self ) -> Purpose
{
self.purpose
}
pub fn current_dir( &self ) -> &Path
{
&self.dir
}
pub fn take_focus_request( &mut self ) -> Option<WidgetId>
{
self.focus.take()
}
pub fn view( &self ) -> Element<Msg>
{
view::view( self )
}
pub fn update( &mut self, msg: FileChooserMsg ) -> Option<Chosen>
{
match msg
{
FileChooserMsg::Listed( dir, result ) => self.listed( &dir, result ),
FileChooserMsg::Enter( dir ) =>
{
self.places_open = false;
self.enter( dir );
}
FileChooserMsg::Up => if let Some( parent ) = self.parent() { self.enter( parent ); },
FileChooserMsg::Pick( path ) => match self.purpose
{
Purpose::Open => return Some( Chosen::Path( path ) ),
Purpose::Save =>
{
self.name = path.file_name().map( |name| name.to_string_lossy().into_owned() ).unwrap_or_default();
self.replacing = None;
self.focus = Some( NAME );
}
},
FileChooserMsg::Name( name ) =>
{
self.name = name;
self.replacing = None;
}
FileChooserMsg::Confirm =>
{
let target = self.target()?;
if self.replaces()
{
self.replacing = Some( target );
return None;
}
return Some( Chosen::Path( target ) );
}
FileChooserMsg::Replace => return self.replacing.take().map( Chosen::Path ),
FileChooserMsg::KeepFile =>
{
self.replacing = None;
self.focus = Some( NAME );
}
FileChooserMsg::NewFolder =>
{
self.new_folder = Some( String::new() );
self.focus = Some( FOLDER_NAME );
}
FileChooserMsg::FolderName( name ) => if let Some( folder ) = &mut self.new_folder { *folder = name; },
FileChooserMsg::CreateFolder =>
{
let path = self.new_folder_path()?;
self.new_folder = None;
let wrap = Arc::clone( &self.wrap );
let made = path.clone();
files::create_dir( path, &self.sender, move |result| wrap( FileChooserMsg::FolderCreated( made, result.map_err( |e| describe( &e ) ) ) ) );
}
FileChooserMsg::CancelFolder => self.new_folder = None,
FileChooserMsg::FolderCreated( path, result ) => match result
{
Ok( () ) => self.enter( path ),
Err( error ) => self.notice = Some( error ),
},
FileChooserMsg::Mount =>
{
let wrap = Arc::clone( &self.wrap );
files::mount( &self.sender, move |result| wrap( FileChooserMsg::Mounted( result.map_err( |e|
{
( e.kind() != io::ErrorKind::Interrupted ).then( || describe( &e ) )
} ) ) ) );
}
FileChooserMsg::Mounted( result ) => match result
{
Ok( mount ) => self.enter( mount.path ),
Err( Some( error ) ) => self.notice = Some( error ),
Err( None ) => {}
},
FileChooserMsg::TogglePlaces => self.places_open = !self.places_open,
FileChooserMsg::ToggleSort => self.sort_open = !self.sort_open,
FileChooserMsg::SetSort( sort ) => self.set_sort( sort ),
FileChooserMsg::CloseNotice => self.notice = None,
FileChooserMsg::Cancel => return Some( Chosen::Cancelled ),
}
None
}
fn msg( &self, msg: FileChooserMsg ) -> Msg
{
( self.wrap )( msg )
}
fn enter( &mut self, dir: PathBuf )
{
self.dir = dir.clone();
self.items.clear();
self.error = None;
self.loading = true;
self.replacing = None;
let wrap = Arc::clone( &self.wrap );
files::list( dir.clone(), &self.sender, move |result| wrap( FileChooserMsg::Listed( dir, result.map_err( |e| describe( &e ) ) ) ) );
}
fn listed( &mut self, dir: &Path, result: Result<Vec<files::Entry>, String> )
{
if dir != self.dir { return; }
self.loading = false;
match result
{
Ok( entries ) =>
{
self.items = items( dir, entries, &self.filters, self.sort );
self.error = None;
}
Err( error ) => self.error = Some( error ),
}
}
fn set_sort( &mut self, sort: Sort )
{
self.sort = sort;
self.items.sort_by( |a, b| sort.compare( a, b ) );
}
fn parent( &self ) -> Option<PathBuf>
{
self.dir.parent().map( Path::to_path_buf )
}
fn target( &self ) -> Option<PathBuf>
{
valid_name( &self.name ).map( |name| self.dir.join( name ) )
}
fn replaces( &self ) -> bool
{
let name = self.name.trim();
self.items.iter().any( |item| !item.is_dir && item.name == name )
}
fn new_folder_path( &self ) -> Option<PathBuf>
{
valid_name( self.new_folder.as_deref()? ).map( |name| self.dir.join( name ) )
}
}
fn valid_name( name: &str ) -> Option<&str>
{
let name = name.trim();
( !name.is_empty() && name != "." && name != ".." && !name.contains( '/' ) ).then_some( name )
}
pub( crate ) fn items( dir: &Path, entries: Vec<files::Entry>, filters: &[ String ], sort: Sort ) -> Vec<Item>
{
let mut items: Vec<Item> = entries.into_iter()
.filter_map( |entry|
{
let name = entry.name.to_string_lossy().into_owned();
if name.starts_with( '.' ) { return None; }
let is_dir = entry.kind == Kind::Directory;
let mime = if is_dir { None } else { files::mime_type( &name ) };
if !is_dir && !filters.is_empty() && !mime.is_some_and( |mime| filters.iter().any( |f| matches( f, mime ) ) ) { return None; }
Some( Item { path: dir.join( &entry.name ), is_dir, size: entry.size, mime, modified: entry.modified, accessed: entry.accessed, name } )
} )
.collect();
items.sort_by( |a, b| sort.compare( a, b ) );
items
}
fn matches( filter: &str, mime: &str ) -> bool
{
match filter.strip_suffix( "/*" )
{
Some( kind ) => mime.split_once( '/' ).is_some_and( |( top, _ )| top == kind ),
None => filter == mime,
}
}
fn describe( error: &io::Error ) -> String
{
match error.kind()
{
io::ErrorKind::NotFound => rust_i18n::t!( "file_chooser.not_found" ).to_string(),
io::ErrorKind::PermissionDenied => rust_i18n::t!( "file_chooser.denied" ).to_string(),
io::ErrorKind::AlreadyExists => rust_i18n::t!( "file_chooser.exists" ).to_string(),
io::ErrorKind::Unsupported => rust_i18n::t!( "file_chooser.unsupported" ).to_string(),
_ => error.to_string(),
}
}