// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! A dialog that picks a file to open, or a folder and a name to save one //! as, over [`crate::files`], alike natively and in a browser. The app //! holds it, puts its [`FileChooser::view`] in its own view, and hands its //! messages to [`FileChooser::update`], which says when the user chose. //! //! ```rust,no_run //! # use ltk::{ ChannelSender, Chosen, Element, FileChooser, FileChooserMsg }; //! # #[ derive( Clone ) ] enum Msg { Chooser( FileChooserMsg ) } //! # struct App { chooser: Option>, sender: ChannelSender } //! # impl App { //! fn open( &mut self ) //! { //! self.chooser = Some( FileChooser::open( &self.sender, Msg::Chooser ).filter( &[ "text/*" ] ) ); //! } //! //! fn update( &mut self, msg: Msg ) //! { //! let Msg::Chooser( msg ) = msg; //! let Some( chooser ) = &mut self.chooser else { return }; //! match chooser.update( msg ) //! { //! Some( Chosen::Path( path ) ) => { self.chooser = None; /* read path */ } //! Some( Chosen::Cancelled ) => self.chooser = None, //! None => {} //! } //! } //! # fn view( &self ) -> Element { self.chooser.as_ref().unwrap().view() } //! # } //! ``` 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; /// The field with the name to save as. pub const NAME: WidgetId = WidgetId( "ltk.file_chooser.name" ); /// The field with the name of a folder to create. pub const FOLDER_NAME: WidgetId = WidgetId( "ltk.file_chooser.folder" ); #[ derive( Clone, Copy, Debug, PartialEq, Eq ) ] pub enum Purpose { Open, Save, } /// What the user chose. #[ derive( Clone, Debug, PartialEq, Eq ) ] pub enum Chosen { /// The file to open, or the one to save as, which the user agreed to replace if it was there. Path( PathBuf ), Cancelled, } /// What happens in a file chooser, for [`FileChooser::update`]. #[ derive( Clone, Debug ) ] pub enum FileChooserMsg { Listed( PathBuf, Result, String> ), Enter( PathBuf ), Up, Pick( PathBuf ), Name( String ), Confirm, Replace, KeepFile, NewFolder, FolderName( String ), CreateFolder, CancelFolder, FolderCreated( PathBuf, Result<(), String> ), Mount, /// `None` when the user dismissed the browser's picker. Mounted( Result> ), TogglePlaces, ToggleSort, SetSort( Sort ), CloseNotice, Cancel, } /// One entry of the folder shown. #[ 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, pub accessed: Option, } pub struct FileChooser { purpose: Purpose, dir: PathBuf, /// Folders first, then the files the filter lets through, in `sort`'s order, the hidden ones left out. items: Vec, sort: Sort, sort_open: bool, name: String, error: Option, loading: bool, filters: Vec, compact: bool, places_open: bool, /// The file saving would replace, while the user is asked. replacing: Option, /// The name of the folder being created. new_folder: Option, notice: Option, focus: Option, sender: ChannelSender, wrap: Arc Msg + Send + Sync>, } impl FileChooser { /// A chooser of a file to open, starting at the home folder. `wrap` makes the app's message of the chooser's own. pub fn open( sender: &ChannelSender, wrap: impl Fn( FileChooserMsg ) -> Msg + Send + Sync + 'static ) -> Self { Self::new( Purpose::Open, String::new(), sender, Arc::new( wrap ) ) } /// A chooser of where to save a file, offering `name`, which it gives the focus. pub fn save( sender: &ChannelSender, wrap: impl Fn( FileChooserMsg ) -> Msg + Send + Sync + 'static, name: impl Into ) -> 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, wrap: Arc 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 } /// Start at `dir` instead of the home folder. pub fn dir( mut self, dir: impl Into ) -> Self { self.enter( dir.into() ); self } /// Show only the files of these types besides the folders: MIME types such as `text/plain`, or a whole kind such as `image/*`. 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 } /// In this order instead of by name, as the app kept it from [`Self::current_sort`]. pub fn sort( mut self, sort: Sort ) -> Self { self.set_sort( sort ); self } pub fn current_sort( &self ) -> Sort { self.sort } /// Laid out for a narrow screen: the places behind a button instead of beside the files. 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 } /// The field to focus, once after it changed; an app hands it on from [`crate::App::take_focus_request`]. pub fn take_focus_request( &mut self ) -> Option { self.focus.take() } pub fn view( &self ) -> Element { view::view( self ) } /// Take one of the chooser's messages; `Some` once the user chose. pub fn update( &mut self, msg: FileChooserMsg ) -> Option { 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 ) ) ) ) ); } /// The listing of `dir`, unless the chooser has moved on to another folder. fn listed( &mut self, dir: &Path, result: Result, 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 { self.dir.parent().map( Path::to_path_buf ) } /// Where to save, once there is a name. fn target( &self ) -> Option { valid_name( &self.name ).map( |name| self.dir.join( name ) ) } /// Whether saving to the target would replace a file the folder holds. 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 { 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 ) } /// What a folder shows: the hidden entries and the files no filter takes left out, folders first, in `sort`'s order. pub( crate ) fn items( dir: &Path, entries: Vec, filters: &[ String ], sort: Sort ) -> Vec { let mut items: Vec = 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, } } /// An error in the user's words. 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(), } }