diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index e6234e3e..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,20 +0,0 @@ -on: - pull_request: - branches: - - master - - develop - -name: Clippy check - -# Make sure CI fails on all warnings, including Clippy lints -env: - RUSTFLAGS: "-Dwarnings" - -jobs: - clippy_check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Run Clippy - run: cargo clippy --all-targets --all-features - diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 00000000..8fd256e4 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,32 @@ +on: + push: + branches: + - master + - develop + pull_request: + branches: + - master + - develop + +name: Rust + +env: + RUSTFLAGS: "-Dwarnings" + +jobs: + lint-fmt-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Cargo check + run: cargo check + + - name: Rustfmt + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features + + - name: Test + run: cargo test --all-features diff --git a/Cargo.toml b/Cargo.toml index af3a2409..e75975d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,26 @@ +[workspace] +members = [ + "examples/*" +] + [package] -name = "egui-file-explorer" +name = "egui-file-dialog" +description = "An easy-to-use file dialog for egui" version = "0.1.0" edition = "2021" +authors = ["fluxxcode"] +repository = "https://github.com/fluxxcode/egui-file-dialog" +homepage = "https://github.com/fluxxcode/egui-file-dialog" +readme = "README.md" +license = "MIT" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +egui = "0.25.0" + +# Used to fetch user folders +directories = "5.0" + +# Used to fetch disks +sysinfo = { version = "0.30.5", default-features = false } diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..3e6048bb --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +Copyright 2024 fluxxcode + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the β€œSoftware”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED β€œAS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 00000000..2ad338c9 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# egui-file-dialog +[![Latest version](https://img.shields.io/crates/v/egui-file-dialog.svg)](https://crates.io/crates/egui-file-dialog) +[![Documentation](https://docs.rs/egui-file-dialog/badge.svg)](https://docs.rs/egui-file-dialog) +[![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/fluxxcode/egui-file-dialog/blob/master/LICENSE) + +This repository provides an easy-to-use file dialog (a.k.a. file explorer, file picker) for [egui](https://github.com/emilk/egui). This makes it possible to use a file dialog directly in the egui application without having to rely on the file explorer of the operating system. + + + +The goal for the future is that parts of the dialog can be dynamically adapted so that it can be used in different situations. One goal, for example, is that the labels can be dynamically adjusted to support different languages. + +The project is currently in a very early version. Some planned features are still missing and some improvements still need to be made. + +**NOTE**: As long as version 1.0.0 has not yet been reached, even minor version increases may contain breaking changes. + +**Currently only tested on Linux and Windows!** + +## Features +- Select a file or a directory +- Save a file (Prompt user for a destination path) +- Create a new folder +- Navigation buttons to open the parent or previous directories +- Search for items in a directory +- Shortcut for user directories (Home, Documents, ...) and system disks +- Resizable window + +## Example +The following example shows the basic use of the file dialog with [eframe](https://github.com/emilk/egui/tree/master/crates/eframe) to select a file. + +Cargo.toml: +```toml +[dependencies] +eframe = "0.25.0" +egui-file-dialog = "0.1.0" +``` + +main.rs: +```rs +use std::path::PathBuf; + +use eframe::egui; +use egui_file_dialog::FileDialog; + +struct MyApp { + file_dialog: FileDialog, + selected_file: Option, +} + +impl MyApp { + pub fn new(_cc: &eframe::CreationContext) -> Self { + Self { + // Create a new file dialog object + file_dialog: FileDialog::new(), + selected_file: None, + } + } +} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + if ui.button("Select file").clicked() { + // Open the file dialog to select a file. + self.file_dialog.select_file(); + } + + ui.label(format!("Selected file: {:?}", self.selected_file)); + + // Update the dialog and check if the user selected a file + if let Some(path) = self.file_dialog.update(ctx).selected() { + self.selected_file = Some(path.to_path_buf()); + } + }); + } +} + +fn main() -> eframe::Result<()> { + eframe::run_native( + "File dialog demo", + eframe::NativeOptions::default(), + Box::new(|ctx| Box::new(MyApp::new(ctx))), + ) +} +``` diff --git a/doc/img/demo.png b/doc/img/demo.png new file mode 100644 index 00000000..05a3352d Binary files /dev/null and b/doc/img/demo.png differ diff --git a/examples/sandbox/Cargo.toml b/examples/sandbox/Cargo.toml new file mode 100644 index 00000000..9764912e --- /dev/null +++ b/examples/sandbox/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "sandbox" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +eframe = { version = "0.25.0", default-features = false, features = ["glow"] } +egui-file-dialog = { version = "0.1.0", path = "../../"} diff --git a/examples/sandbox/README.md b/examples/sandbox/README.md new file mode 100644 index 00000000..0a76cb61 --- /dev/null +++ b/examples/sandbox/README.md @@ -0,0 +1,5 @@ +Sandbox app used during development of the file exporter. + +``` +cargo run -p sandbox +``` diff --git a/examples/sandbox/src/main.rs b/examples/sandbox/src/main.rs new file mode 100644 index 00000000..d0a00854 --- /dev/null +++ b/examples/sandbox/src/main.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; + +use eframe::egui; +use egui_file_dialog::{DialogMode, DialogState, FileDialog}; + +struct MyApp { + file_dialog: FileDialog, + + selected_directory: Option, + selected_file: Option, + saved_file: Option, +} + +impl MyApp { + pub fn new(_cc: &eframe::CreationContext) -> Self { + Self { + file_dialog: FileDialog::new(), + + selected_directory: None, + selected_file: None, + saved_file: None, + } + } +} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.heading("My egui application"); + egui::widgets::global_dark_light_mode_buttons(ui); + + ui.add_space(5.0); + + if ui.button("Select directory").clicked() { + self.file_dialog.select_directory(); + } + ui.label(format!("Selected directory: {:?}", self.selected_directory)); + + ui.add_space(5.0); + + if ui.button("Select file").clicked() { + self.file_dialog.select_file(); + } + ui.label(format!("Selected file: {:?}", self.selected_file)); + + ui.add_space(5.0); + + if ui.button("Save file").clicked() { + self.file_dialog.save_file(); + } + ui.label(format!("File to save: {:?}", self.saved_file)); + + match self.file_dialog.update(ctx).state() { + DialogState::Selected(path) => match self.file_dialog.mode() { + DialogMode::SelectDirectory => self.selected_directory = Some(path), + DialogMode::SelectFile => self.selected_file = Some(path), + DialogMode::SaveFile => self.saved_file = Some(path), + }, + _ => {} + }; + }); + } +} + +fn main() -> eframe::Result<()> { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([1080.0, 720.0]), + ..Default::default() + }; + + eframe::run_native( + "My egui application", + options, + Box::new(|ctx| Box::new(MyApp::new(ctx))), + ) +} diff --git a/examples/save_file/Cargo.toml b/examples/save_file/Cargo.toml new file mode 100644 index 00000000..85a7c9c1 --- /dev/null +++ b/examples/save_file/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "save_file" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +eframe = { version = "0.25.0", default-features = false, features = ["glow"] } +egui-file-dialog = { version = "0.1.0", path = "../../"} diff --git a/examples/save_file/README.md b/examples/save_file/README.md new file mode 100644 index 00000000..266c5fa9 --- /dev/null +++ b/examples/save_file/README.md @@ -0,0 +1,7 @@ +Example showing how to save a file using the file dialog. + +``` +cargo run -p save_file +``` + +![](screenshot.png) diff --git a/examples/save_file/screenshot.png b/examples/save_file/screenshot.png new file mode 100644 index 00000000..bc8c5e99 Binary files /dev/null and b/examples/save_file/screenshot.png differ diff --git a/examples/save_file/src/main.rs b/examples/save_file/src/main.rs new file mode 100644 index 00000000..0990ed15 --- /dev/null +++ b/examples/save_file/src/main.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +use eframe::egui; +use egui_file_dialog::FileDialog; + +struct MyApp { + file_dialog: FileDialog, + file_path: Option, +} + +impl MyApp { + pub fn new(_cc: &eframe::CreationContext) -> Self { + Self { + file_dialog: FileDialog::new(), + file_path: None, + } + } +} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + if ui.button("Save file").clicked() { + self.file_dialog.save_file(); + } + + ui.label(format!("File to save: {:?}", self.file_path)); + + if let Some(path) = self.file_dialog.update(ctx).selected() { + self.file_path = Some(path.to_path_buf()); + } + }); + } +} + +fn main() -> eframe::Result<()> { + eframe::run_native( + "File dialog example", + eframe::NativeOptions::default(), + Box::new(|ctx| Box::new(MyApp::new(ctx))), + ) +} diff --git a/examples/select_directory/Cargo.toml b/examples/select_directory/Cargo.toml new file mode 100644 index 00000000..fd7097ce --- /dev/null +++ b/examples/select_directory/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "select_directory" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +eframe = { version = "0.25.0", default-features = false, features = ["glow"] } +egui-file-dialog = { version = "0.1.0", path = "../../"} diff --git a/examples/select_directory/README.md b/examples/select_directory/README.md new file mode 100644 index 00000000..a53d2051 --- /dev/null +++ b/examples/select_directory/README.md @@ -0,0 +1,7 @@ +Example showing how to select a directory using the file dialog. + +``` +cargo run -p select_directory +``` + +![](screenshot.png) diff --git a/examples/select_directory/screenshot.png b/examples/select_directory/screenshot.png new file mode 100644 index 00000000..26128898 Binary files /dev/null and b/examples/select_directory/screenshot.png differ diff --git a/examples/select_directory/src/main.rs b/examples/select_directory/src/main.rs new file mode 100644 index 00000000..e4331fb5 --- /dev/null +++ b/examples/select_directory/src/main.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +use eframe::egui; +use egui_file_dialog::FileDialog; + +struct MyApp { + file_dialog: FileDialog, + selected_directory: Option, +} + +impl MyApp { + pub fn new(_cc: &eframe::CreationContext) -> Self { + Self { + file_dialog: FileDialog::new(), + selected_directory: None, + } + } +} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + if ui.button("Select directory").clicked() { + self.file_dialog.select_directory(); + } + + ui.label(format!("Selected directory: {:?}", self.selected_directory)); + + if let Some(path) = self.file_dialog.update(ctx).selected() { + self.selected_directory = Some(path.to_path_buf()); + } + }); + } +} + +fn main() -> eframe::Result<()> { + eframe::run_native( + "File dialog example", + eframe::NativeOptions::default(), + Box::new(|ctx| Box::new(MyApp::new(ctx))), + ) +} diff --git a/examples/select_file/Cargo.toml b/examples/select_file/Cargo.toml new file mode 100644 index 00000000..3ba49999 --- /dev/null +++ b/examples/select_file/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "select_file" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +eframe = { version = "0.25.0", default-features = false, features = ["glow"] } +egui-file-dialog = { version = "0.1.0", path = "../../"} diff --git a/examples/select_file/README.md b/examples/select_file/README.md new file mode 100644 index 00000000..9b56fc1d --- /dev/null +++ b/examples/select_file/README.md @@ -0,0 +1,7 @@ +Example showing how to select a file using the file dialog. + +``` +cargo run -p select_file +``` + +![](screenshot.png) diff --git a/examples/select_file/screenshot.png b/examples/select_file/screenshot.png new file mode 100644 index 00000000..8c133e94 Binary files /dev/null and b/examples/select_file/screenshot.png differ diff --git a/examples/select_file/src/main.rs b/examples/select_file/src/main.rs new file mode 100644 index 00000000..d330471e --- /dev/null +++ b/examples/select_file/src/main.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +use eframe::egui; +use egui_file_dialog::FileDialog; + +struct MyApp { + file_dialog: FileDialog, + selected_file: Option, +} + +impl MyApp { + pub fn new(_cc: &eframe::CreationContext) -> Self { + Self { + file_dialog: FileDialog::new(), + selected_file: None, + } + } +} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + if ui.button("Select file").clicked() { + self.file_dialog.select_file(); + } + + ui.label(format!("Selected file: {:?}", self.selected_file)); + + if let Some(path) = self.file_dialog.update(ctx).selected() { + self.selected_file = Some(path.to_path_buf()); + } + }); + } +} + +fn main() -> eframe::Result<()> { + eframe::run_native( + "File dialog example", + eframe::NativeOptions::default(), + Box::new(|ctx| Box::new(MyApp::new(ctx))), + ) +} diff --git a/src/create_directory_dialog.rs b/src/create_directory_dialog.rs new file mode 100644 index 00000000..589e3aa7 --- /dev/null +++ b/src/create_directory_dialog.rs @@ -0,0 +1,183 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::ui; + +pub struct CreateDirectoryResponse { + /// Contains the path to the directory that was created. + directory: Option, +} + +impl CreateDirectoryResponse { + /// Creates a new response object with the given directory. + pub fn new(directory: &Path) -> Self { + Self { + directory: Some(directory.to_path_buf()), + } + } + + /// Creates a new response with no directory set. + pub fn new_empty() -> Self { + Self { directory: None } + } + + /// Returns the directory that was created. + /// None is returned if no directory has been created yet. + pub fn directory(&self) -> Option { + self.directory.clone() + } +} + +/// A dialog to create new folder. +pub struct CreateDirectoryDialog { + /// If the dialog is currently open + open: bool, + /// If the update method is called for the first time. + /// Used to initialize some stuff and scroll to the dialog. + init: bool, + /// The directory that is currently open and where the folder is created. + directory: Option, + + /// Buffer to hold the data of the folder name input + input: String, + /// This contains the error message if the folder name is invalid + error: Option, +} + +impl CreateDirectoryDialog { + /// Creates a new dialog with default values + pub fn new() -> Self { + Self { + open: false, + init: false, + directory: None, + + input: String::new(), + error: None, + } + } + + /// Resets the dialog and opens it. + pub fn open(&mut self, directory: PathBuf) { + self.reset(); + + self.open = true; + self.init = true; + self.directory = Some(directory); + } + + /// Closes and resets the dialog. + pub fn close(&mut self) { + self.reset(); + } + + /// Main update function of the dialog. Should be called in every frame + /// in which the dialog is to be displayed. + pub fn update(&mut self, ui: &mut egui::Ui) -> CreateDirectoryResponse { + if !self.open { + return CreateDirectoryResponse::new_empty(); + } + + let mut result = CreateDirectoryResponse::new_empty(); + + ui.horizontal(|ui| { + ui.label("πŸ—€"); + + let response = ui.text_edit_singleline(&mut self.input); + + if self.init { + response.scroll_to_me(Some(egui::Align::Center)); + response.request_focus(); + + self.error = self.validate_input(); + self.init = false; + } + + if response.changed() { + self.error = self.validate_input(); + } + + if ui::button_enabled_disabled(ui, "βœ”", self.error.is_none()) { + result = self.create_directory(); + } + + if ui.button("βœ–").clicked() { + self.close(); + } + }); + + if let Some(err) = &self.error { + ui.add_space(5.0); + ui.label(err); + } + + result + } + + /// Returns if the dialog is currently open + pub fn is_open(&self) -> bool { + self.open + } + + /// Creates a new folder in the current directory. + /// The variable `input` is used as the folder name. + /// Might change the `error` variable when an error occurred creating the new folder. + fn create_directory(&mut self) -> CreateDirectoryResponse { + if let Some(mut dir) = self.directory.clone() { + dir.push(self.input.as_str()); + + match fs::create_dir(&dir) { + Ok(()) => { + self.close(); + return CreateDirectoryResponse::new(dir.as_path()); + } + Err(err) => { + self.error = Some(format!("Error: {}", err)); + return CreateDirectoryResponse::new_empty(); + } + } + } + + // This error should not occur because the create_directory function is only + // called when the dialog is open and the directory is set. + // If this error occurs, there is most likely a bug in the code. + self.error = Some("No directory given".to_string()); + + CreateDirectoryResponse::new_empty() + } + + /// Validates the folder name input. + /// Returns None if the name is valid. Otherwise returns the error message. + fn validate_input(&mut self) -> Option { + if self.input.is_empty() { + return Some("Name of the folder can not be empty".to_string()); + } + + if let Some(mut x) = self.directory.clone() { + x.push(self.input.as_str()); + + if x.is_dir() { + return Some("A directory with the name already exists".to_string()); + } + if x.is_file() { + return Some("A file with the name already exists".to_string()); + } + } else { + // This error should not occur because the validate_input function is only + // called when the dialog is open and the directory is set. + // If this error occurs, there is most likely a bug in the code. + return Some("No directory given".to_string()); + } + + None + } + + /// Resets the dialog. + /// Configuration variables are not changed. + fn reset(&mut self) { + self.open = false; + self.init = false; + self.directory = None; + self.input.clear(); + } +} diff --git a/src/data/directory_content.rs b/src/data/directory_content.rs new file mode 100644 index 00000000..6f2d8773 --- /dev/null +++ b/src/data/directory_content.rs @@ -0,0 +1,124 @@ +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +/// Contains the metadata of a directory item. +/// This struct is mainly there so that the metadata can be loaded once and not that +/// a request has to be sent to the OS every frame using, for example, `path.is_file()`. +#[derive(Default, Clone, PartialEq, Eq)] +pub struct DirectoryEntry { + path: PathBuf, + is_directory: bool, + is_system_file: bool, +} + +impl DirectoryEntry { + /// Creates a new directory entry from a path + pub fn from_path(path: &Path) -> Self { + Self { + path: path.to_path_buf(), + is_directory: path.is_dir(), + is_system_file: !path.is_dir() && !path.is_file(), + } + } + + /// Returns true if the item is a directory. + /// False is returned if the item is a file or the path did not exist when the + /// DirectoryEntry object was created. + pub fn is_dir(&self) -> bool { + self.is_directory + } + + /// Returns true if the item is a file. + /// False is returned if the item is a directory or the path did not exist when the + /// DirectoryEntry object was created. + pub fn is_file(&self) -> bool { + !self.is_directory + } + + /// Returns true if the item is a system file. + pub fn is_system_file(&self) -> bool { + self.is_system_file + } + + /// Returns the path of the directory item. + pub fn to_path_buf(&self) -> PathBuf { + self.path.clone() + } + + /// Returns the file name of the directory item. + pub fn file_name(&self) -> &str { + self.path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + } +} + +/// Contains the content of a directory. +#[derive(Default)] +pub struct DirectoryContent { + content: Vec, +} + +impl DirectoryContent { + /// Create a new object with empty content + pub fn new() -> Self { + Self { content: vec![] } + } + + /// Create a new DirectoryContent object and loads the contents of the given path. + /// Use include_files to include or exclude files in the content list. + pub fn from_path(path: &Path, include_files: bool) -> io::Result { + Ok(Self { + content: load_directory(path, include_files)?, + }) + } + + /// Very simple wrapper methods of the contents .iter() method. + /// No trait is implemented since this is currently only used internal. + pub fn iter(&self) -> std::slice::Iter<'_, DirectoryEntry> { + self.content.iter() + } + + /// Pushes a new item to the content. + pub fn push(&mut self, item: DirectoryEntry) { + self.content.push(item); + } +} + +/// Loads the contents of the given directory. +fn load_directory(path: &Path, include_files: bool) -> io::Result> { + let paths = fs::read_dir(path)?; + + let mut result: Vec = Vec::new(); + for path in paths { + match path { + Ok(entry) => { + let entry = DirectoryEntry::from_path(entry.path().as_path()); + + if entry.is_system_file() { + continue; + } + + if !include_files && entry.is_file() { + continue; + } + + result.push(entry); + } + Err(_) => continue, + }; + } + + result.sort_by(|a, b| match a.is_dir() == b.is_dir() { + true => a.file_name().cmp(b.file_name()), + false => match a.is_dir() { + true => std::cmp::Ordering::Less, + false => std::cmp::Ordering::Greater, + }, + }); + + // TODO: Implement "Show hidden files and folders" option + + Ok(result) +} diff --git a/src/data/disks.rs b/src/data/disks.rs new file mode 100644 index 00000000..b14d1d5d --- /dev/null +++ b/src/data/disks.rs @@ -0,0 +1,71 @@ +use std::path::{Path, PathBuf}; + +/// Wrapper above the sysinfo::Disk struct. +/// Used for helper functions and so that more flexibility is guaranteed in the future if +/// the names of the disks are generated dynamically. +#[derive(Default, Clone, PartialEq, Eq)] +pub struct Disk { + mount_point: PathBuf, + display_name: String, +} + +impl Disk { + /// Create a new Disk object based on the data of a sysinfo::Disk. + pub fn from_sysinfo_disk(disk: &sysinfo::Disk) -> Self { + Self { + mount_point: disk.mount_point().to_path_buf(), + display_name: Self::gen_display_name(disk), + } + } + + /// Returns the mount point of the disk + pub fn mount_point(&self) -> &Path { + &self.mount_point + } + + /// Returns the display name of the disk + pub fn display_name(&self) -> &str { + &self.display_name + } + + fn gen_display_name(disk: &sysinfo::Disk) -> String { + // TODO: Get display name of the devices. + // Currently on Linux it returns "/dev/sda1" and on Windows it returns an + // empty string for the C:\\ drive. + let name = disk.name().to_str().unwrap_or_default().to_string(); + + // Try using the mount point as the display name if the specified name + // from sysinfo::Disk is empty or contains invalid characters + if name.is_empty() { + return disk.mount_point().to_str().unwrap_or_default().to_string(); + } + + name + } +} + +/// Wrapper above the sysinfo::Disks struct +#[derive(Default)] +pub struct Disks { + disks: Vec, +} + +impl Disks { + /// Creates a new Disks object with a refreshed list of the system disks. + pub fn new_with_refreshed_list() -> Self { + let disks = sysinfo::Disks::new_with_refreshed_list(); + + let mut result: Vec = Vec::new(); + for disk in disks.iter() { + result.push(Disk::from_sysinfo_disk(disk)); + } + + Self { disks: result } + } + + /// Very simple wrapper method of the disks .iter() method. + /// No trait is implemented since this is currently only used internal. + pub fn iter(&self) -> std::slice::Iter<'_, Disk> { + self.disks.iter() + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs new file mode 100644 index 00000000..4d6afc42 --- /dev/null +++ b/src/data/mod.rs @@ -0,0 +1,8 @@ +mod directory_content; +pub use directory_content::{DirectoryContent, DirectoryEntry}; + +mod disks; +pub use disks::Disks; + +mod user_directories; +pub use user_directories::UserDirectories; diff --git a/src/data/user_directories.rs b/src/data/user_directories.rs new file mode 100644 index 00000000..9e354c8d --- /dev/null +++ b/src/data/user_directories.rs @@ -0,0 +1,77 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +/// Wrapper above directories::UserDirs. +/// Currently only used to canonicalize the paths. +#[derive(Default, Clone)] +pub struct UserDirectories { + home_dir: Option, + + audio_dir: Option, + desktop_dir: Option, + document_dir: Option, + download_dir: Option, + picture_dir: Option, + video_dir: Option, +} + +impl UserDirectories { + /// Creates a new UserDirectories object. + /// Returns None if no valid home directory path could be retrieved from the operating system. + pub fn new() -> Option { + if let Some(dirs) = directories::UserDirs::new() { + return Some(Self { + home_dir: Self::canonicalize(Some(dirs.home_dir())), + + audio_dir: Self::canonicalize(dirs.audio_dir()), + desktop_dir: Self::canonicalize(dirs.desktop_dir()), + document_dir: Self::canonicalize(dirs.document_dir()), + download_dir: Self::canonicalize(dirs.download_dir()), + picture_dir: Self::canonicalize(dirs.picture_dir()), + video_dir: Self::canonicalize(dirs.video_dir()), + }); + } + + None + } + + pub fn home_dir(&self) -> Option<&Path> { + self.home_dir.as_deref() + } + + pub fn audio_dir(&self) -> Option<&Path> { + self.audio_dir.as_deref() + } + + pub fn desktop_dir(&self) -> Option<&Path> { + self.desktop_dir.as_deref() + } + + pub fn document_dir(&self) -> Option<&Path> { + self.document_dir.as_deref() + } + + pub fn download_dir(&self) -> Option<&Path> { + self.download_dir.as_deref() + } + + pub fn picture_dir(&self) -> Option<&Path> { + self.picture_dir.as_deref() + } + + pub fn video_dir(&self) -> Option<&Path> { + self.video_dir.as_deref() + } + + /// Canonicalizes the given paths. Returns None if an error occurred. + fn canonicalize(path: Option<&Path>) -> Option { + if let Some(path) = path { + return match fs::canonicalize(path) { + Ok(p) => Some(p), + Err(_) => None, + }; + } + + None + } +} diff --git a/src/file_dialog.rs b/src/file_dialog.rs new file mode 100644 index 00000000..642b6368 --- /dev/null +++ b/src/file_dialog.rs @@ -0,0 +1,967 @@ +#![warn(missing_docs)] // Let's keep the public API well documented! + +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use crate::create_directory_dialog::CreateDirectoryDialog; + +use crate::data::{DirectoryContent, DirectoryEntry, Disks, UserDirectories}; +use crate::ui; + +/// Represents the mode the file dialog is currently in. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum DialogMode { + /// When the dialog is currently used to select a file + SelectFile, + + /// When the dialog is currently used to select a directory + SelectDirectory, + + /// When the dialog is currently used to save a file + SaveFile, +} + +/// Represents the state the file dialog is currently in. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum DialogState { + /// The dialog is currently open and the user can perform the desired actions. + Open, + + /// The dialog is currently closed and not visible. + Closed, + + /// The user has selected a folder or file or specified a destination path for saving a file. + Selected(PathBuf), + + /// The user cancelled the dialog and didn't select anything. + Cancelled, +} + +/// Represents a file dialog instance. +/// +/// The `FileDialog` instance can be used multiple times and for different actions. +/// +/// # Examples +/// +/// ``` +/// use egui_file_dialog::FileDialog; +/// +/// struct MyApp { +/// file_dialog: FileDialog, +/// } +/// +/// impl MyApp { +/// fn update(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { +/// if ui.button("Select a file").clicked() { +/// self.file_dialog.select_file(); +/// } +/// +/// if let Some(path) = self.file_dialog.update(ctx).selected() { +/// println!("Selected file: {:?}", path); +/// } +/// } +/// } +/// ``` +pub struct FileDialog { + /// The mode the dialog is currently in + mode: DialogMode, + /// The state the dialog is currently in + state: DialogState, + /// The first directory that will be opened when the dialog opens + initial_directory: PathBuf, + /// If files are displayed in addition to directories. + /// This option will be ignored when mode == DialogMode::SelectFile. + show_files: bool, + + /// The user directories like Home or Documents. + /// These are loaded once when the dialog is created or when the refresh() method is called. + user_directories: Option, + /// The currently mounted system disks. + /// These are loaded once when the dialog is created or when the refresh() method is called. + system_disks: Disks, + + /// Contains the directories that the user opened. Every newly opened directory + /// is pushed to the vector. + /// Used for the navigation buttons to load the previous or next directory. + directory_stack: Vec, + /// An offset from the back of directory_stack telling which directory is currently open. + /// If 0, the user is currently in the latest open directory. + /// If not 0, the user has used the "Previous directory" button and has + /// opened previously opened directories. + directory_offset: usize, + /// The content of the currently open directory + directory_content: DirectoryContent, + /// This variable contains the error message if an error occurred while loading the directory. + directory_error: Option, + + /// The currently used window title. + /// This changes depending on the mode the dialog is in. + window_title: String, + /// The default size of the window. + default_window_size: egui::Vec2, + + /// The dialog that is shown when the user wants to create a new directory. + create_directory_dialog: CreateDirectoryDialog, + + /// The item that the user currently selected. + /// Can be a directory or a folder. + selected_item: Option, + /// The default filename when opening the dialog in DialogMode::SaveFile mode. + default_file_name: String, + /// Buffer for the input of the file name when the dialog is in "SaveFile" mode. + file_name_input: String, + /// This variables contains the error message if the file_name_input is invalid. + /// This can be the case, for example, if a file or folder with the name already exists. + file_name_input_error: Option, + + /// If we should scroll to the item selected by the user in the next frame. + scroll_to_selection: bool, + /// Buffer containing the value of the search input. + search_value: String, +} + +impl Default for FileDialog { + /// Creates a new file dialog instance with default values. + fn default() -> Self { + Self::new() + } +} + +impl FileDialog { + /// Creates a new file dialog instance with default values. + pub fn new() -> Self { + FileDialog { + mode: DialogMode::SelectDirectory, + state: DialogState::Closed, + initial_directory: std::env::current_dir().unwrap_or_default(), + show_files: true, + + user_directories: UserDirectories::new(), + system_disks: Disks::new_with_refreshed_list(), + + directory_stack: vec![], + directory_offset: 0, + directory_content: DirectoryContent::new(), + directory_error: None, + + window_title: String::from("Select directory"), + default_window_size: egui::Vec2::new(650.0, 370.0), + + create_directory_dialog: CreateDirectoryDialog::new(), + + selected_item: None, + default_file_name: String::new(), + file_name_input: String::new(), + file_name_input_error: None, + + scroll_to_selection: false, + search_value: String::new(), + } + } + + /// Sets the first loaded directory when the dialog opens. + /// If the path is a file, the file's parent directory is used. If the path then has no + /// parent directory or cannot be loaded, the user will receive an error. + /// However, the user directories and system disk allow the user to still select a file in + /// the event of an error. + /// + /// Relative and absolute paths are allowed, but absolute paths are recommended. + pub fn initial_directory(mut self, directory: PathBuf) -> Self { + self.initial_directory = directory.clone(); + self + } + + /// Sets the default size of the window. + pub fn default_window_size(mut self, size: egui::Vec2) -> Self { + self.default_window_size = size; + self + } + + /// Sets the default file name when opening the dialog in `DialogMode::SaveFile` mode. + pub fn default_file_name(mut self, name: &str) -> Self { + self.default_file_name = name.to_string(); + self + } + + /// Opens the file dialog in the given mode with the given options. + /// This function resets the file dialog and takes care for the variables that need to be + /// set when opening the file dialog. + /// + /// Returns the result of the operation to load the initial directory. + /// + /// The `show_files` parameter will be ignored when the mode equals `DialogMode::SelectFile`. + pub fn open(&mut self, mode: DialogMode, mut show_files: bool) -> io::Result<()> { + self.reset(); + + // Try to use the parent directory if the initial directory is a file. + // If the path then has no parent directory, the user will see an error that the path + // does not exist. However, using the user directories or disks, the user is still able + // to select an item or save a file. + if self.initial_directory.is_file() { + if let Some(parent) = self.initial_directory.parent() { + self.initial_directory = parent.to_path_buf(); + } + } + + if mode == DialogMode::SelectFile { + show_files = true; + } + + if mode == DialogMode::SaveFile { + self.file_name_input = self.default_file_name.clone(); + } + + self.mode = mode; + self.state = DialogState::Open; + self.show_files = show_files; + + self.window_title = match mode { + DialogMode::SelectDirectory => "πŸ“ Select Folder".to_string(), + DialogMode::SelectFile => "πŸ“‚ Open File".to_string(), + DialogMode::SaveFile => "πŸ“₯ Save File".to_string(), + }; + + self.load_directory(&self.initial_directory.clone()) + } + + /// Shortcut function to open the file dialog to prompt the user to select a directory. + /// If used, no files in the directories will be shown to the user. + /// Use the `open()` method instead, if you still want to display files to the user. + /// This function resets the file dialog. Configuration variables such as + /// `initial_directory` are retained. + /// + /// The function ignores the result of the initial directory loading operation. + pub fn select_directory(&mut self) { + let _ = self.open(DialogMode::SelectDirectory, false); + } + + /// Shortcut function to open the file dialog to prompt the user to select a file. + /// This function resets the file dialog. Configuration variables such as + /// `initial_directory` are retained. + /// + /// The function ignores the result of the initial directory loading operation. + pub fn select_file(&mut self) { + let _ = self.open(DialogMode::SelectFile, false); + } + + /// Shortcut function to open the file dialog to prompt the user to save a file. + /// This function resets the file dialog. Configuration variables such as + /// `initial_directory` are retained. + /// + /// The function ignores the result of the initial directory loading operation. + pub fn save_file(&mut self) { + let _ = self.open(DialogMode::SaveFile, true); + } + + /// Returns the mode the dialog is currently in. + pub fn mode(&self) -> DialogMode { + self.mode + } + + /// Returns the state the dialog is currently in. + pub fn state(&self) -> DialogState { + self.state.clone() + } + + /// Returns the directory or file that the user selected, or the target file + /// if the dialog is in `DialogMode::SaveFile` mode. + /// + /// None is returned when the user has not yet selected an item. + pub fn selected(&self) -> Option<&Path> { + match &self.state { + DialogState::Selected(path) => Some(path), + _ => None, + } + } + + /// The main update method that should be called every frame if the dialog is to be visible. + /// + /// This function has no effect if the dialog state is currently not `DialogState::Open`. + pub fn update(&mut self, ctx: &egui::Context) -> &Self { + if self.state != DialogState::Open { + return self; + } + + let mut is_open = true; + + egui::Window::new(&self.window_title) + .open(&mut is_open) + .default_size(self.default_window_size) + .min_width(335.0) + .min_height(200.0) + .collapsible(false) + .show(ctx, |ui| { + egui::TopBottomPanel::top("fe_top_panel") + .resizable(false) + .show_inside(ui, |ui| { + self.ui_update_top_panel(ctx, ui); + }); + + egui::SidePanel::left("fe_left_panel") + .resizable(true) + .default_width(150.0) + .width_range(90.0..=250.0) + .show_inside(ui, |ui| { + self.ui_update_left_panel(ctx, ui); + }); + + egui::TopBottomPanel::bottom("fe_bottom_panel") + .resizable(false) + .show_inside(ui, |ui| { + self.ui_update_bottom_panel(ctx, ui); + }); + + egui::CentralPanel::default().show_inside(ui, |ui| { + self.ui_update_central_panel(ui); + }); + }); + + // User closed the window without finishing the dialog + if !is_open { + self.cancel(); + } + + self + } + + /// Updates the top panel of the dialog. Including the navigation buttons, + /// the current path display, the reload button and the search field. + fn ui_update_top_panel(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + const NAV_BUTTON_SIZE: egui::Vec2 = egui::Vec2::new(25.0, 25.0); + + ui.horizontal(|ui| { + // Navigation buttons + if let Some(x) = self.current_directory() { + if ui::button_sized_enabled_disabled(ui, NAV_BUTTON_SIZE, "⏢", x.parent().is_some()) + { + let _ = self.load_parent_directory(); + } + } else { + let _ = ui::button_sized_enabled_disabled(ui, NAV_BUTTON_SIZE, "⏢", false); + } + + if ui::button_sized_enabled_disabled( + ui, + NAV_BUTTON_SIZE, + "⏴", + self.directory_offset + 1 < self.directory_stack.len(), + ) { + let _ = self.load_previous_directory(); + } + + if ui::button_sized_enabled_disabled( + ui, + NAV_BUTTON_SIZE, + "⏡", + self.directory_offset != 0, + ) { + let _ = self.load_next_directory(); + } + + if ui::button_sized_enabled_disabled( + ui, + NAV_BUTTON_SIZE, + "+", + !self.create_directory_dialog.is_open(), + ) { + if let Some(x) = self.current_directory() { + self.create_directory_dialog.open(x.to_path_buf()); + } + } + + // Leave area for the reload button and search window + let path_display_width = ui.available_width() - 180.0; + + // Current path display + egui::Frame::default() + .stroke(egui::Stroke::new( + 1.0, + ctx.style().visuals.window_stroke.color, + )) + .inner_margin(egui::Margin { + left: 4.0, + right: 8.0, + top: 4.0, + bottom: 4.0, + }) + .rounding(egui::Rounding::from(4.0)) + .show(ui, |ui| { + ui.style_mut().always_scroll_the_only_direction = true; + ui.style_mut().spacing.scroll.bar_width = 8.0; + + egui::ScrollArea::horizontal() + .auto_shrink([false, false]) + .stick_to_right(true) + .max_width(path_display_width) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.style_mut().spacing.item_spacing.x /= 2.5; + + let mut path = PathBuf::new(); + + if let Some(data) = self.current_directory() { + #[cfg(windows)] + let mut drive_letter = String::from("\\"); + + for (i, segment) in data.iter().enumerate() { + path.push(segment); + + #[cfg(windows)] + let mut file_name = segment.to_str().unwrap_or(""); + + #[cfg(windows)] + { + // Skip the path namespace prefix generated by + // fs::canonicalize() on Windows + if i == 0 && file_name.contains(r"\\?\") { + drive_letter = file_name.replace(r"\\?\", ""); + continue; + } + + // Replace the root segment with the disk letter + if i == 1 && segment == "\\" { + file_name = drive_letter.as_str(); + } else if i != 0 { + ui.label(">"); + } + } + + #[cfg(not(windows))] + let file_name = segment.to_str().unwrap_or(""); + + #[cfg(not(windows))] + if i != 0 { + ui.label(">"); + } + + let is_last = data.components().count() - 1 == i; + + if ui.selectable_label(is_last, file_name).clicked() { + let _ = self.load_directory(path.as_path()); + return; + } + } + } + }); + }); + }); + + // Reload button + if ui + .add_sized(NAV_BUTTON_SIZE, egui::Button::new("⟲")) + .clicked() + { + self.refresh(); + } + + // Search bar + egui::Frame::default() + .stroke(egui::Stroke::new( + 1.0, + ctx.style().visuals.window_stroke.color, + )) + .inner_margin(egui::Margin::symmetric(4.0, 4.0)) + .rounding(egui::Rounding::from(4.0)) + .show(ui, |ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| { + ui.add_space(ctx.style().spacing.item_spacing.y); + ui.label("πŸ”"); + ui.add_sized( + egui::Vec2::new(ui.available_width(), 0.0), + egui::TextEdit::singleline(&mut self.search_value), + ); + }); + }); + }); + + ui.add_space(ctx.style().spacing.item_spacing.y); + } + + /// Updates the left panel of the dialog. Including the list of the user directories (Places) + /// and system disks. + fn ui_update_left_panel(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + egui::containers::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add_space(ctx.style().spacing.item_spacing.y * 2.0); + + self.ui_update_user_directories(ui); + + ui.add_space(ctx.style().spacing.item_spacing.y * 4.0); + + self.ui_update_devices(ui); + }); + }); + } + + /// Updates the bottom panel showing the selected item and main action buttons. + fn ui_update_bottom_panel(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + const BUTTON_SIZE: egui::Vec2 = egui::Vec2::new(78.0, 20.0); + + ui.add_space(5.0); + + ui.horizontal(|ui| { + match &self.mode { + DialogMode::SelectDirectory => ui.label("Selected directory:"), + DialogMode::SelectFile => ui.label("Selected file:"), + DialogMode::SaveFile => ui.label("File name:"), + }; + + match &self.mode { + DialogMode::SelectDirectory | DialogMode::SelectFile => { + if self.is_selection_valid() { + if let Some(x) = &self.selected_item { + ui.colored_label(ui.style().visuals.selection.bg_fill, x.file_name()); + } + } + } + DialogMode::SaveFile => { + let response = ui.add(egui::TextEdit::singleline(&mut self.file_name_input)); + + if response.changed() { + self.file_name_input_error = self.validate_file_name_input(); + } + + if let Some(x) = &self.file_name_input_error { + // TODO: Use error icon instead + ui.label(x); + } + } + }; + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let label = match &self.mode { + DialogMode::SelectDirectory | DialogMode::SelectFile => "πŸ—€ Open", + DialogMode::SaveFile => "πŸ“₯ Save", + }; + + if ui::button_sized_enabled_disabled(ui, BUTTON_SIZE, label, self.is_selection_valid()) + { + match &self.mode { + DialogMode::SelectDirectory | DialogMode::SelectFile => { + // self.selected_item should always contain a value, + // since self.is_selection_valid() validates the selection and + // returns false if the selection is none. + if let Some(selection) = self.selected_item.clone() { + self.finish(selection.to_path_buf()); + } + } + DialogMode::SaveFile => { + // self.current_directory should always contain a value, + // since self.is_selection_valid() makes sure there is no + // file_name_input_error. The file_name_input_error + // gets validated every time something changes + // by the validate_file_name_input, which sets an error + // if we are currently not in a directory. + if let Some(path) = self.current_directory() { + let mut full_path = path.to_path_buf(); + full_path.push(&self.file_name_input); + + self.finish(full_path); + } + } + } + } + + ui.add_space(ctx.style().spacing.item_spacing.y); + + if ui + .add_sized(BUTTON_SIZE, egui::Button::new("🚫 Cancel")) + .clicked() + { + self.cancel(); + } + }); + } + + /// Updates the central panel, including the list of items in the currently open directory. + fn ui_update_central_panel(&mut self, ui: &mut egui::Ui) { + if let Some(err) = &self.directory_error { + ui.centered_and_justified(|ui| { + ui.colored_label(egui::Color32::RED, format!("⚠ {}", err)); + }); + return; + } + + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + egui::containers::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + // Temporarily take ownership of the directory contents to be able to + // update it in the for loop using load_directory. + // Otherwise we would get an error that `*self` cannot be borrowed as mutable + // more than once at a time. + // Make sure to return the function after updating the directory_content, + // otherwise the change will be overwritten with the last statement + // of the function. + let data = std::mem::take(&mut self.directory_content); + + for path in data.iter() { + let file_name = path.file_name(); + + if !self.search_value.is_empty() + && !file_name + .to_lowercase() + .contains(&self.search_value.to_lowercase()) + { + continue; + } + + let icon = match path.is_dir() { + true => "πŸ—€", + _ => "πŸ–Ή", + }; + + let mut selected = false; + if let Some(x) = &self.selected_item { + selected = x == path; + } + + let response = + ui.selectable_label(selected, format!("{} {}", icon, file_name)); + + if selected && self.scroll_to_selection { + response.scroll_to_me(Some(egui::Align::Center)); + } + + if response.clicked() { + self.select_item(path); + } + + if response.double_clicked() { + if path.is_dir() { + let _ = self.load_directory(&path.to_path_buf()); + return; + } + + self.select_item(path); + + if self.is_selection_valid() { + // self.selected_item should always contain a value + // since self.is_selection_valid() validates the selection + // and returns false if the selection is none. + if let Some(selection) = self.selected_item.clone() { + self.finish(selection.to_path_buf()); + } + } + } + } + + self.scroll_to_selection = false; + self.directory_content = data; + + if let Some(path) = self.create_directory_dialog.update(ui).directory() { + let entry = DirectoryEntry::from_path(&path); + + self.directory_content.push(entry.clone()); + self.select_item(&entry); + } + }); + }); + } + + /// Updates the list of the user directories (Places). + fn ui_update_user_directories(&mut self, ui: &mut egui::Ui) { + if let Some(dirs) = self.user_directories.clone() { + ui.label("Places"); + + if let Some(path) = dirs.home_dir() { + if ui + .selectable_label(self.current_directory() == Some(path), "🏠 Home") + .clicked() + { + let _ = self.load_directory(path); + } + } + + if let Some(path) = dirs.desktop_dir() { + if ui + .selectable_label(self.current_directory() == Some(path), "πŸ–΅ Desktop") + .clicked() + { + let _ = self.load_directory(path); + } + } + if let Some(path) = dirs.document_dir() { + if ui + .selectable_label(self.current_directory() == Some(path), "πŸ— Documents") + .clicked() + { + let _ = self.load_directory(path); + } + } + if let Some(path) = dirs.download_dir() { + if ui + .selectable_label(self.current_directory() == Some(path), "πŸ“₯ Downloads") + .clicked() + { + let _ = self.load_directory(path); + } + } + if let Some(path) = dirs.audio_dir() { + if ui + .selectable_label(self.current_directory() == Some(path), "🎡 Audio") + .clicked() + { + let _ = self.load_directory(path); + } + } + if let Some(path) = dirs.picture_dir() { + if ui + .selectable_label(self.current_directory() == Some(path), "πŸ–Ό Pictures") + .clicked() + { + let _ = self.load_directory(path); + } + } + if let Some(path) = dirs.video_dir() { + if ui + .selectable_label(self.current_directory() == Some(path), "🎞 Videos") + .clicked() + { + let _ = self.load_directory(path); + } + } + } + } + + /// Updates the list of the system disks (Disks). + fn ui_update_devices(&mut self, ui: &mut egui::Ui) { + ui.label("Devices"); + + let disks = std::mem::take(&mut self.system_disks); + + for disk in disks.iter() { + if ui + .selectable_label(false, format!("πŸ–΄ {}", disk.display_name())) + .clicked() + { + let _ = self.load_directory(disk.mount_point()); + } + } + + self.system_disks = disks; + } + + /// Resets the dialog to use default values. + /// Configuration variables such as `initial_directory` are retained. + fn reset(&mut self) { + self.state = DialogState::Closed; + self.show_files = true; + + self.system_disks = Disks::new_with_refreshed_list(); + + self.directory_stack = vec![]; + self.directory_offset = 0; + self.directory_content = DirectoryContent::new(); + + self.create_directory_dialog = CreateDirectoryDialog::new(); + + self.selected_item = None; + self.file_name_input = String::new(); + self.scroll_to_selection = false; + self.search_value = String::new(); + } + + /// Refreshes the dialog. + /// Including the user directories, system disks and currently open directory. + fn refresh(&mut self) { + self.user_directories = UserDirectories::new(); + self.system_disks = Disks::new_with_refreshed_list(); + + let _ = self.reload_directory(); + } + + /// Finishes the dialog. + /// `selected_item`` is the item that was selected by the user. + fn finish(&mut self, selected_item: PathBuf) { + self.state = DialogState::Selected(selected_item); + } + + /// Cancels the dialog. + fn cancel(&mut self) { + self.state = DialogState::Cancelled; + } + + /// Gets the currently open directory. + fn current_directory(&self) -> Option<&Path> { + if let Some(x) = self.directory_stack.iter().nth_back(self.directory_offset) { + return Some(x.as_path()); + } + + None + } + + /// Checks whether the selection or the file name entered is valid. + /// What is checked depends on the mode the dialog is currently in. + fn is_selection_valid(&self) -> bool { + if let Some(selection) = &self.selected_item { + return match &self.mode { + DialogMode::SelectDirectory => selection.is_dir(), + DialogMode::SelectFile => selection.is_file(), + DialogMode::SaveFile => self.file_name_input_error.is_none(), + }; + } + + if self.mode == DialogMode::SaveFile && self.file_name_input_error.is_none() { + return true; + } + + false + } + + /// Validates the file name entered by the user. + /// + /// Returns None if the file name is valid. Otherwise returns an error message. + fn validate_file_name_input(&self) -> Option { + if self.file_name_input.is_empty() { + return Some("The file name cannot be empty".to_string()); + } + + if let Some(x) = self.current_directory() { + let mut full_path = x.to_path_buf(); + full_path.push(self.file_name_input.as_str()); + + if full_path.is_dir() { + return Some("A directory with the name already exists".to_string()); + } + if full_path.is_file() { + return Some("A file with the name already exists".to_string()); + } + } else { + // There is most likely a bug in the code if we get this error message! + return Some("Currently not in a directory".to_string()); + } + + None + } + + /// Marks the given item as the selected directory item. + /// Also updates the file_name_input to the name of the selected item. + fn select_item(&mut self, dir_entry: &DirectoryEntry) { + self.selected_item = Some(dir_entry.clone()); + + if self.mode == DialogMode::SaveFile && dir_entry.is_file() { + self.file_name_input = dir_entry.file_name().to_string(); + self.file_name_input_error = self.validate_file_name_input(); + } + } + + /// Loads the next directory in the directory_stack. + /// If directory_offset is 0 and there is no other directory to load, Ok() is returned and + /// nothing changes. + /// Otherwise, the result of the directory loading operation is returned. + fn load_next_directory(&mut self) -> io::Result<()> { + if self.directory_offset == 0 { + // There is no next directory that can be loaded + return Ok(()); + } + + self.directory_offset -= 1; + + // Copy path and load directory + if let Some(path) = self.current_directory() { + return self.load_directory_content(path.to_path_buf().as_path()); + } + + Ok(()) + } + + /// Loads the previous directory the user opened. + /// If there is no previous directory left, Ok() is returned and nothing changes. + /// Otherwise, the result of the directory loading operation is returned. + fn load_previous_directory(&mut self) -> io::Result<()> { + if self.directory_offset + 1 >= self.directory_stack.len() { + // There is no previous directory that can be loaded + return Ok(()); + } + + self.directory_offset += 1; + + // Copy path and load directory + if let Some(path) = self.current_directory() { + return self.load_directory_content(path.to_path_buf().as_path()); + } + + Ok(()) + } + + /// Loads the parent directory of the currently open directory. + /// If the directory doesn't have a parent, Ok() is returned and nothing changes. + /// Otherwise, the result of the directory loading operation is returned. + fn load_parent_directory(&mut self) -> io::Result<()> { + if let Some(x) = self.current_directory() { + if let Some(x) = x.to_path_buf().parent() { + return self.load_directory(x); + } + } + + Ok(()) + } + + /// Reloads the currently open directory. + /// If no directory is currently open, Ok() will be returned. + /// Otherwise, the result of the directory loading operation is returned. + fn reload_directory(&mut self) -> io::Result<()> { + if let Some(x) = self.current_directory() { + return self.load_directory_content(x.to_path_buf().as_path()); + } + + Ok(()) + } + + /// Loads the given directory and updates the `directory_stack`. + /// The function deletes all directories from the `directory_stack` that are currently + /// stored in the vector before the `directory_offset`. + fn load_directory(&mut self, path: &Path) -> io::Result<()> { + // Do not load the same directory again. + // Use reload_directory if the content of the directory should be updated. + if let Some(x) = self.current_directory() { + if x == path { + return Ok(()); + } + } + + if self.directory_offset != 0 && self.directory_stack.len() > self.directory_offset { + self.directory_stack + .drain(self.directory_stack.len() - self.directory_offset..); + } + + let full_path = match fs::canonicalize(path) { + Ok(path) => path, + Err(err) => { + self.directory_error = Some(err.to_string()); + return Err(err); + } + }; + + self.directory_stack.push(full_path); + self.directory_offset = 0; + + self.load_directory_content(path) + } + + /// Loads the directory content of the given path. + fn load_directory_content(&mut self, path: &Path) -> io::Result<()> { + self.directory_error = None; + + self.directory_content = match DirectoryContent::from_path(path, self.show_files) { + Ok(content) => content, + Err(err) => { + self.directory_error = Some(err.to_string()); + return Err(err); + } + }; + + self.create_directory_dialog.close(); + self.scroll_to_selection = true; + + if self.mode == DialogMode::SaveFile { + self.file_name_input_error = self.validate_file_name_input(); + } + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 0c45deb2..ed303999 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,61 @@ +//! # egui-file-dialog +//! +//! An easy-to-use file dialog (a.k.a. file explorer, file picker) for [egui](https://github.com/emilk/egui). +//! +//! The project is currently in a very early version. Some planned features are still missing and some improvements still need to be made. +//! +//! **Currently only tested on Linux and Windows!** +//! +//! Read more about the project: +//! +//! ### Features +//! - Select a file or a directory +//! - Save a file (Prompt user for a destination path) +//! - Create a new folder +//! - Navigation buttons to open the parent or previous directories +//! - Search for items in a directory +//! - Shortcut for user directories (Home, Documents, ...) and system disks +//! - Resizable window +//! +//! ### A simple example +//! +//! The following example shows of how you can use the file dialog to let the user select a file. \ +//! See the full example at: +//! +//! ``` +//! use egui_file_dialog::FileDialog; +//! +//! struct MyApp { +//! file_dialog: FileDialog, +//! } +//! +//! impl MyApp { +//! pub fn new() -> Self { +//! Self { +//! // Create a new FileDialog instance +//! file_dialog: FileDialog::new(), +//! } +//! } +//! } +//! +//! impl MyApp { +//! fn update(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { +//! if ui.button("Select file").clicked() { +//! // Open the file dialog to select a file +//! self.file_dialog.select_file(); +//! } +//! +//! // Update the dialog and check if the user selected a file +//! if let Some(path) = self.file_dialog.update(ctx).selected() { +//! println!("Selected file: {:?}", path); +//! } +//! } +//! } +//! ``` -// Implement your code here +mod file_dialog; +pub use file_dialog::{DialogMode, DialogState, FileDialog}; + +mod create_directory_dialog; +mod data; +mod ui; diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 00000000..173533ab --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,43 @@ +/// Adds a dynamically sized button to the UI that can be enabled or disabled. +/// Returns true if the button is clicked. Otherwise None is returned. +pub fn button_enabled_disabled(ui: &mut egui::Ui, text: &str, enabled: bool) -> bool { + if !enabled { + let button = egui::Button::new(text) + .stroke(egui::Stroke::NONE) + .fill(get_disabled_fill_color(ui)); + + let _ = ui.add(button); + + return false; + } + + ui.add(egui::Button::new(text)).clicked() +} + +/// Adds a fixed sized button to the UI that can be enabled or disabled. +/// Returns true if the button is clicked. Otherwise None is returned. +pub fn button_sized_enabled_disabled( + ui: &mut egui::Ui, + size: egui::Vec2, + text: &str, + enabled: bool, +) -> bool { + if !enabled { + let button = egui::Button::new(text) + .stroke(egui::Stroke::NONE) + .fill(get_disabled_fill_color(ui)); + + let _ = ui.add_sized(size, button); + + return false; + } + + ui.add_sized(size, egui::Button::new(text)).clicked() +} + +/// Returns the fill color of disabled buttons +#[inline] +fn get_disabled_fill_color(ui: &egui::Ui) -> egui::Color32 { + let c = ui.style().visuals.widgets.noninteractive.bg_fill; + egui::Color32::from_rgba_premultiplied(c.r(), c.g(), c.b(), 100) +}