ferroui_dialogs/internal/
managed_file_chooser_sources.rs1use super::{ManagedFileChooserItemType, ManagedFileChooserNavigationItem};
2use ferroui_base::collections::FerroList;
3use ferroui_base::platform::storage::file_io::path;
4use ferroui_base::utilities::ByteSizeHelper;
5use ferroui_controls::platform::MountedVolumeInfo;
6use std::cell::RefCell;
7use std::path::{Path, PathBuf};
8use std::rc::Rc;
9
10pub struct ManagedFileChooserSources {
16 get_user_directories: RefCell<Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>>>,
17 get_file_system_roots: RefCell<Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>>>,
18 get_all_items_delegate: RefCell<Rc<dyn Fn(&ManagedFileChooserSources) -> Vec<ManagedFileChooserNavigationItem>>>,
19}
20
21ferroui_base::ferro_thread_local! {
22 static MOUNTED_VOLUMES: FerroList<MountedVolumeInfo> = FerroList::new();
23}
24
25#[derive(Clone, Copy)]
28enum SpecialFolder {
29 Desktop,
30 UserProfile,
31 MyDocuments,
32 MyMusic,
33 MyPictures,
34 MyVideos,
35}
36
37const FOLDERS: [SpecialFolder; 6] = [
38 SpecialFolder::Desktop,
39 SpecialFolder::UserProfile,
40 SpecialFolder::MyDocuments,
41 SpecialFolder::MyMusic,
42 SpecialFolder::MyPictures,
43 SpecialFolder::MyVideos,
44];
45
46fn home_directory() -> Option<PathBuf> {
47 let variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
48 std::env::var_os(variable).filter(|home| !home.is_empty()).map(PathBuf::from)
49}
50
51fn read_xdg_directory(home: &Path, key: &str, fallback: &str) -> PathBuf {
55 if let Some(value) = std::env::var_os(key).filter(|value| !value.is_empty()) {
56 return PathBuf::from(value);
57 }
58
59 let config_home = std::env::var_os("XDG_CONFIG_HOME")
60 .filter(|config_home| !config_home.is_empty())
61 .map(PathBuf::from)
62 .unwrap_or_else(|| home.join(".config"));
63 if let Ok(content) = std::fs::read_to_string(config_home.join("user-dirs.dirs")) {
64 if let Some(directory) = parse_xdg_user_directory(&content, home, key) {
65 return directory;
66 }
67 }
68
69 home.join(fallback)
70}
71
72fn parse_xdg_user_directory(content: &str, home: &Path, key: &str) -> Option<PathBuf> {
76 for line in content.lines() {
77 let line = line.trim_start();
78 let Some(rest) = line.strip_prefix(key) else { continue };
79 let Some(rest) = rest.trim_start().strip_prefix('=') else { continue };
80 let Some(rest) = rest.trim_start().strip_prefix('"') else { continue };
81 let Some(end) = rest.find('"') else { continue };
82 let value = &rest[..end];
83
84 if let Some(relative) = value.strip_prefix("$HOME/") {
85 return Some(home.join(relative));
86 }
87 if value.starts_with('/') {
88 return Some(PathBuf::from(value));
89 }
90 }
91 None
92}
93
94fn get_folder_path(folder: SpecialFolder) -> String {
97 let Some(home) = home_directory() else {
98 return String::new();
99 };
100
101 let path = if cfg!(windows) {
102 match folder {
103 SpecialFolder::Desktop => home.join("Desktop"),
104 SpecialFolder::UserProfile => home,
105 SpecialFolder::MyDocuments => home.join("Documents"),
106 SpecialFolder::MyMusic => home.join("Music"),
107 SpecialFolder::MyPictures => home.join("Pictures"),
108 SpecialFolder::MyVideos => home.join("Videos"),
109 }
110 } else {
111 match folder {
112 SpecialFolder::UserProfile | SpecialFolder::MyDocuments => home,
113 SpecialFolder::Desktop => read_xdg_directory(&home, "XDG_DESKTOP_DIR", "Desktop"),
114 SpecialFolder::MyMusic if cfg!(target_os = "macos") => home.join("Music"),
115 SpecialFolder::MyMusic => read_xdg_directory(&home, "XDG_MUSIC_DIR", "Music"),
116 SpecialFolder::MyPictures if cfg!(target_os = "macos") => home.join("Pictures"),
117 SpecialFolder::MyPictures => read_xdg_directory(&home, "XDG_PICTURES_DIR", "Pictures"),
118 SpecialFolder::MyVideos => read_xdg_directory(&home, "XDG_VIDEOS_DIR", "Videos"),
119 }
120 };
121 path.to_string_lossy().into_owned()
122}
123
124impl Default for ManagedFileChooserSources {
125 fn default() -> Self {
126 Self::new()
127 }
128}
129
130impl ManagedFileChooserSources {
131 pub fn new() -> Self {
132 Self {
133 get_user_directories: RefCell::new(Rc::new(Self::default_get_user_directories)),
134 get_file_system_roots: RefCell::new(Rc::new(Self::default_get_file_system_roots)),
135 get_all_items_delegate: RefCell::new(Rc::new(Self::default_get_all_items)),
136 }
137 }
138
139 pub fn get_user_directories(&self) -> Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>> {
140 self.get_user_directories.borrow().clone()
141 }
142
143 pub fn set_get_user_directories(&self, value: Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>>) {
144 *self.get_user_directories.borrow_mut() = value;
145 }
146
147 pub fn get_file_system_roots(&self) -> Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>> {
148 self.get_file_system_roots.borrow().clone()
149 }
150
151 pub fn set_get_file_system_roots(&self, value: Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>>) {
152 *self.get_file_system_roots.borrow_mut() = value;
153 }
154
155 pub fn get_all_items_delegate(&self) -> Rc<dyn Fn(&ManagedFileChooserSources) -> Vec<ManagedFileChooserNavigationItem>> {
156 self.get_all_items_delegate.borrow().clone()
157 }
158
159 pub fn set_get_all_items_delegate(
160 &self,
161 value: Rc<dyn Fn(&ManagedFileChooserSources) -> Vec<ManagedFileChooserNavigationItem>>,
162 ) {
163 *self.get_all_items_delegate.borrow_mut() = value;
164 }
165
166 pub fn get_all_items(&self) -> Vec<ManagedFileChooserNavigationItem> {
167 (self.get_all_items_delegate())(self)
168 }
169
170 pub fn mounted_volumes() -> FerroList<MountedVolumeInfo> {
173 MOUNTED_VOLUMES.with(FerroList::clone)
174 }
175
176 pub fn default_get_all_items(sources: &ManagedFileChooserSources) -> Vec<ManagedFileChooserNavigationItem> {
177 let mut items = (sources.get_user_directories())();
178 items.extend((sources.get_file_system_roots())());
179 items
180 }
181
182 pub fn default_get_user_directories() -> Vec<ManagedFileChooserNavigationItem> {
183 let mut paths: Vec<String> = Vec::new();
184 for path in FOLDERS.iter().map(|folder| get_folder_path(*folder)) {
185 if !paths.contains(&path) {
186 paths.push(path);
187 }
188 }
189
190 paths
191 .into_iter()
192 .filter(|d| !d.trim().is_empty())
193 .filter(|d| Path::new(d).is_dir())
194 .map(|d| ManagedFileChooserNavigationItem {
195 item_type: ManagedFileChooserItemType::Folder,
196 display_name: Some(path::get_file_name(&d).to_string()),
197 path: Some(d),
198 })
199 .collect()
200 }
201
202 pub fn default_get_file_system_roots() -> Vec<ManagedFileChooserNavigationItem> {
203 Self::mounted_volumes()
204 .to_vec()
205 .into_iter()
206 .filter_map(|x| {
207 let mut display_name = x.volume_label.clone();
208
209 if display_name.is_none() & (x.volume_size_bytes > 0) {
210 display_name = Some(format!("{} Volume", ByteSizeHelper::to_string(x.volume_size_bytes, true)));
211 }
212
213 let path = x.volume_path.clone();
215 let files = path.as_deref().map(std::fs::read_dir);
216 if !matches!(files, Some(Ok(_))) {
217 return None;
218 }
219
220 Some(ManagedFileChooserNavigationItem {
221 item_type: ManagedFileChooserItemType::Volume,
222 display_name,
223 path,
224 })
225 })
226 .collect()
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
234
235 #[test]
236 fn all_items_are_the_user_directories_followed_by_the_roots() {
237 let sources = ManagedFileChooserSources::new();
238 let item = |name: &str, item_type| ManagedFileChooserNavigationItem {
239 display_name: Some(name.to_string()),
240 path: Some(format!("/{name}")),
241 item_type,
242 };
243 sources.set_get_user_directories(Rc::new(move || vec![item("home", ManagedFileChooserItemType::Folder)]));
244 sources.set_get_file_system_roots(Rc::new(move || vec![item("mnt", ManagedFileChooserItemType::Volume)]));
245
246 let names: Vec<_> = sources.get_all_items().into_iter().map(|i| i.display_name.unwrap()).collect();
247 assert_eq!(vec!["home", "mnt"], names);
248 }
249
250 #[test]
251 fn roots_are_the_listable_mounted_volumes() {
252 let root = std::env::temp_dir();
253 let volumes = ManagedFileChooserSources::mounted_volumes();
254 volumes.clear();
255 volumes.add(MountedVolumeInfo {
256 volume_label: None,
257 volume_path: Some(root.to_string_lossy().into_owned()),
258 volume_size_bytes: 1500,
259 });
260 volumes.add(MountedVolumeInfo {
261 volume_label: Some("Missing".to_string()),
262 volume_path: Some("/this/volume/does/not/exist".to_string()),
263 volume_size_bytes: 0,
264 });
265
266 let roots = ManagedFileChooserSources::default_get_file_system_roots();
267 volumes.clear();
268
269 assert_eq!(1, roots.len());
270 assert_eq!(Some("1.5KB Volume".to_string()), roots[0].display_name);
271 assert_eq!(ManagedFileChooserItemType::Volume, roots[0].item_type);
272 }
273
274 #[test]
275 fn user_directories_exist_and_are_distinct() {
276 let directories = ManagedFileChooserSources::default_get_user_directories();
277 for (index, directory) in directories.iter().enumerate() {
278 let path = directory.path.as_deref().unwrap();
279 assert!(Path::new(path).is_dir());
280 assert_eq!(ManagedFileChooserItemType::Folder, directory.item_type);
281 assert!(directories[index + 1..].iter().all(|other| other.path.as_deref() != Some(path)));
282 }
283 }
284
285 #[test]
286 fn xdg_entries_are_read_relative_to_home_or_absolute() {
287 let home = Path::new("/home/user");
288 let content = "# comment\nXDG_MUSIC_DIR=\"$HOME/Tunes\"\nXDG_VIDEOS_DIR=\"/media/videos\"\n";
289
290 assert_eq!(Some(PathBuf::from("/home/user/Tunes")), parse_xdg_user_directory(content, home, "XDG_MUSIC_DIR"));
291 assert_eq!(Some(PathBuf::from("/media/videos")), parse_xdg_user_directory(content, home, "XDG_VIDEOS_DIR"));
292 assert_eq!(None, parse_xdg_user_directory(content, home, "XDG_DESKTOP_DIR"));
293 }
294}