diff --git a/Cargo.toml b/Cargo.toml index 67e4fea6..a15a747b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,18 +21,28 @@ dunce = "1.0.5" sysinfo = { version = "0.32", default-features = false, features = ["disk"] } # persistent storage serde = { version = "1", features = ["derive"], optional = true } +# meta-data storage +indexmap = { version = "2.6.0", features = ["serde"], optional = true } + +# info panel meta-data display +image-meta = { version = "0.1.2", optional = true } +chrono = { version = "0.4.38", optional = true } [dev-dependencies] eframe = { version = "0.29.1", default-features = false, features = [ "glow", "persistence", ] } -egui-file-dialog = { path = "." } +egui-file-dialog = { path = "." , features = ["information_view"] } +egui_extras = { version = "0.29", features = ["all_loaders"] } +# required by the egui loaders +image = { version = "0.25.5", features = ["bmp", "jpeg", "gif", "png", "tiff", "rayon"] } [features] default = ["serde", "default_fonts"] serde = ["dep:serde"] default_fonts = ["egui/default_fonts"] +information_view = ["dep:chrono", "image-meta", "indexmap"] [lints.rust] unsafe_code = "warn" @@ -54,3 +64,4 @@ struct_field_names = { level = "allow", priority = 13 } missing_fields_in_debug = { level = "allow", priority = 14 } missing_errors_doc = { level = "allow", priority = 15 } module_name_repetitions = { level = "allow", priority = 16 } +cast_precision_loss = { level = "allow", priority = 17 } diff --git a/examples/README.md b/examples/README.md index f6ae54d1..3eb1b732 100644 --- a/examples/README.md +++ b/examples/README.md @@ -76,3 +76,25 @@ cargo run --example save_file ``` ![Screenshot](../media/examples/save_file.png) + + +## Pick File with Information View + +Example showing how to pick a file and display file information using the `InformationView`. + +Requires the feature `information_view` as well as these dependencies: + +```toml +[dependencies] +egui-file-dialog = { version = "*", features = ["information_view"] } +egui_extras = { version = "0.29", features = ["all_loaders"] } +# required by the egui loaders +image = { version = "0.25.5", features = ["bmp", "jpeg", "gif", "png", "tiff", "rayon"] } +``` + +```shell +cargo run --example pick_file_with_information_view +``` + +![Screenshot](../media/examples/information_view.png) + diff --git a/examples/pick_file_with_information_view.rs b/examples/pick_file_with_information_view.rs new file mode 100644 index 00000000..0e96c6cd --- /dev/null +++ b/examples/pick_file_with_information_view.rs @@ -0,0 +1,78 @@ +use std::path::PathBuf; + +use eframe::egui; +use egui_file_dialog::information_panel::InformationPanel; +use egui_file_dialog::{DialogState, FileDialog}; + +struct MyApp { + file_dialog: FileDialog, + information_panel: InformationPanel, + selected_file: Option, +} + +impl MyApp { + pub fn new(_cc: &eframe::CreationContext) -> Self { + Self { + file_dialog: FileDialog::new(), + information_panel: InformationPanel::default() + .add_file_preview("csv", |ui, item| { + ui.label("CSV preview:"); + if let Some(mut content) = item.content() { + egui::ScrollArea::vertical() + .max_height(ui.available_height()) + .show(ui, |ui| { + ui.add(egui::TextEdit::multiline(&mut content).code_editor()); + }); + } + }) + // add additional metadata loader + .add_metadata_loader("pdf", |other_meta_data, path| { + // as a simple example, just show the Filename of the PDF + other_meta_data.insert("PDF Filename".to_string(), format!("{path:?}")); + }), + 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.pick_file(); + } + + self.file_dialog.set_right_panel_width(200.0); + + if let Some(path) = self + .file_dialog + .update_with_right_panel_ui(ctx, &mut |ui, dia| { + self.information_panel.ui(ui, dia); + }) + .picked() + { + self.selected_file = Some(path.to_path_buf()); + } + + match self.file_dialog.state() { + DialogState::Closed | DialogState::Cancelled => { + self.information_panel.forget_all_stored_images(ui); + } + _ => {} + } + + ui.label(format!("Selected file: {:?}", self.selected_file)); + }); + } +} + +fn main() -> eframe::Result<()> { + eframe::run_native( + "File dialog example", + eframe::NativeOptions::default(), + Box::new(|ctx| { + egui_extras::install_image_loaders(&ctx.egui_ctx); + Ok(Box::new(MyApp::new(ctx))) + }), + ) +} diff --git a/media/examples/information_view.png b/media/examples/information_view.png new file mode 100644 index 00000000..798b546f Binary files /dev/null and b/media/examples/information_view.png differ diff --git a/src/config/mod.rs b/src/config/mod.rs index ee4a47ec..be076ace 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -188,6 +188,9 @@ pub struct FileDialogConfig { /// If the search input in the top panel should be visible. pub show_search: bool, + /// Set the width of the right panel, if used + pub right_panel_width: Option, + /// If the sidebar with the shortcut directories such as /// “Home”, “Documents” etc. should be visible. pub show_left_panel: bool, @@ -260,6 +263,7 @@ impl Default for FileDialogConfig { show_system_files_option: true, show_search: true, + right_panel_width: None, show_left_panel: true, show_pinned_folders: true, show_places: true, diff --git a/src/data/directory_content.rs b/src/data/directory_content.rs index d5673e07..c39b286c 100644 --- a/src/data/directory_content.rs +++ b/src/data/directory_content.rs @@ -1,20 +1,29 @@ +use crate::config::{FileDialogConfig, FileFilter}; +use egui::mutex::Mutex; use std::path::{Path, PathBuf}; use std::sync::{mpsc, Arc}; use std::time::SystemTime; use std::{fs, io, thread}; -use egui::mutex::Mutex; - -use crate::config::{FileDialogConfig, FileFilter}; - /// Contains the metadata of a directory item. +#[derive(Debug, Default, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Metadata { + pub size: Option, + pub last_modified: Option, + pub created: Option, + pub file_type: Option, +} + +/// Contains the information of a directory item. /// -/// This struct is mainly there so that the metadata can be loaded once and not that +/// This struct is mainly there so that the information and 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(Debug, Default, Clone)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct DirectoryEntry { path: PathBuf, + metadata: Metadata, is_directory: bool, is_system_file: bool, icon: String, @@ -25,8 +34,18 @@ pub struct DirectoryEntry { impl DirectoryEntry { /// Creates a new directory entry from a path pub fn from_path(config: &FileDialogConfig, path: &Path) -> Self { + let mut metadata = Metadata::default(); + + if let Ok(md) = fs::metadata(path) { + metadata.size = Some(md.len()); + metadata.last_modified = md.modified().ok(); + metadata.created = md.created().ok(); + metadata.file_type = Some(format!("{:?}", md.file_type())); + } + Self { path: path.to_path_buf(), + metadata, is_directory: path.is_dir(), is_system_file: !path.is_dir() && !path.is_file(), icon: gen_path_icon(config, path), @@ -34,6 +53,11 @@ impl DirectoryEntry { } } + /// Returns the metadata of the directory entry. + pub const fn metadata(&self) -> &Metadata { + &self.metadata + } + /// Checks if the path of the current directory entry matches the other directory entry. pub fn path_eq(&self, other: &Self) -> bool { other.as_path() == self.as_path() diff --git a/src/data/mod.rs b/src/data/mod.rs index c267555f..cdae0f0d 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -5,4 +5,5 @@ mod disks; pub use disks::{Disk, Disks}; mod user_directories; + pub use user_directories::UserDirectories; diff --git a/src/file_dialog.rs b/src/file_dialog.rs index 7b0e82b1..c5ca2d97 100644 --- a/src/file_dialog.rs +++ b/src/file_dialog.rs @@ -442,6 +442,16 @@ impl FileDialog { self } + /// Sets the width of the right panel. + pub fn set_right_panel_width(&mut self, width: f32) { + self.config.right_panel_width = Some(width); + } + + /// Clears the width of the right panel by setting it to None. + pub fn clear_right_panel_width(&mut self) { + self.config.right_panel_width = None; + } + /// Do an [update](`Self::update`) with a custom right panel ui. /// /// Example use cases: @@ -1168,6 +1178,11 @@ impl FileDialog { pub fn state(&self) -> DialogState { self.state.clone() } + + /// Get the window Id + pub const fn get_window_id(&self) -> egui::Id { + self.window_id + } } /// UI methods @@ -1213,13 +1228,16 @@ impl FileDialog { // Optionally, show a custom right panel (see `update_with_custom_right_panel`) if let Some(f) = right_panel_fn { - egui::SidePanel::right(self.window_id.with("right_panel")) + let mut right_panel = egui::SidePanel::right(self.window_id.with("right_panel")) // Unlike the left panel, we have no control over the contents, so // we don't restrict the width. It's up to the user to make the UI presentable. - .resizable(true) - .show_inside(ui, |ui| { - f(ui, self); - }); + .resizable(true); + if let Some(width) = self.config.right_panel_width { + right_panel = right_panel.default_width(width); + } + right_panel.show_inside(ui, |ui| { + f(ui, self); + }); } egui::TopBottomPanel::bottom(self.window_id.with("bottom_panel")) diff --git a/src/information_panel.rs b/src/information_panel.rs new file mode 100644 index 00000000..bc3b1262 --- /dev/null +++ b/src/information_panel.rs @@ -0,0 +1,459 @@ +#![cfg(feature = "information_view")] + +use crate::{DirectoryEntry, FileDialog}; +use chrono::{DateTime, Local}; +use egui::ahash::{HashMap, HashMapExt}; +use egui::{Direction, Layout, Ui, Vec2}; +use indexmap::{IndexMap, IndexSet}; +use std::fs::File; +use std::io::{self, Read}; +use std::path::PathBuf; + +type SupportedPreviewFilesMap = HashMap>; +type SupportedPreviewImagesMap = + HashMap)>>; +type SupportedAdditionalMetaFilesMap = + HashMap, &PathBuf)>>; + +fn format_pixels(pixels: u32) -> String { + const K: u32 = 1_000; + const M: u32 = K * 1_000; + + if pixels >= K { + format!("{:.2} MPx", f64::from(pixels) / f64::from(M)) + } else { + format!("{pixels} Px") + } +} + +/// Wrapper for the `DirectoryEntry` struct, that also adds the option to store text content +#[derive(Debug)] +pub struct InfoPanelEntry { + /// Directory Item containing info like path + pub directory_entry: DirectoryEntry, + /// Optional text content of the file + pub content: Option, +} + +impl InfoPanelEntry { + /// Create a new `InfoPanelEntry` object + pub const fn new(item: DirectoryEntry) -> Self { + Self { + directory_entry: item, + content: None, + } + } +} + +impl InfoPanelEntry { + /// Returns the content of the directory item, if available + pub fn content(&self) -> Option<&str> { + self.content.as_deref() + } + + /// Mutably borrow content + pub fn content_mut(&mut self) -> &mut Option { + &mut self.content + } +} + +/// The `InformationPanel` struct provides a panel to display metadata and previews of files. +/// It supports text-based file previews, image previews, and displays file metadata. +/// +/// # Fields +/// - `load_text_content`: Flag to control whether text content should be loaded for preview. +/// - `supported_files`: A hashmap mapping file extensions to their respective preview rendering functions. +pub struct InformationPanel { + panel_entry: Option, + /// Flag to control whether text content should be loaded for preview. + pub load_text_content: bool, + /// Max chars that should be loaded for preview of text files. + pub text_content_max_chars: usize, + /// Path of the current item that is selected + loaded_file_name: PathBuf, + /// Map that contains the handler for specific file types (by file extension) + supported_preview_files: SupportedPreviewFilesMap, + /// Map that contains the handler for image types (by file extension) + supported_preview_images: SupportedPreviewImagesMap, + /// Map that contains the additional metadata loader for specific file types (by file extension) + additional_meta_files: SupportedAdditionalMetaFilesMap, + /// Other metadata (loaded by the loader in `additional_meta_files`) + other_meta_data: IndexMap, + /// Stores the images already loaded by the egui loaders. + stored_images: IndexSet, +} + +impl Default for InformationPanel { + /// Creates a new `InformationPanel` instance with default configurations. + /// Pre-configures support for several text-based and image file extensions. + /// + /// # Returns + /// A new instance of `InformationPanel`. + fn default() -> Self { + let mut supported_files = HashMap::new(); + let mut supported_images = HashMap::new(); + let mut additional_meta_files = HashMap::new(); + + for ext in ["png", "jpg", "jpeg", "bmp", "gif"] { + additional_meta_files.insert( + ext.to_string(), + Box::new( + |other_meta_data: &mut IndexMap, path: &PathBuf| { + if let Ok(meta) = image_meta::load_from_file(&path) { + let (width, height) = (meta.dimensions.width, meta.dimensions.height); + // For image files, show dimensions and color space + other_meta_data + .insert("Dimensions".to_string(), format!("{width} x {height}")); + other_meta_data + .insert("Pixel Count".to_string(), format_pixels(width * height)); + other_meta_data + .insert("Colorspace".to_string(), format!("{:?}", meta.color)); + other_meta_data + .insert("Format".to_string(), format!("{:?}", meta.format)); + } + }, + ) as Box, &PathBuf)>, + ); + } + + // Add preview support for common text file extensions + for text_extension in [ + "txt", "json", "md", "toml", "rtf", "xml", "rs", "py", "c", "h", "cpp", "hpp", + ] { + supported_files.insert( + text_extension.to_string(), + Box::new(|ui: &mut Ui, item: &InfoPanelEntry| { + if let Some(mut content) = item.content() { + egui::ScrollArea::vertical() + .max_height(ui.available_height()) + .show(ui, |ui| { + ui.add(egui::TextEdit::multiline(&mut content).code_editor()); + }); + } + }) as Box, + ); + } + + // Add preview support for JPEG and PNG image files + supported_images.insert( + "jpg".to_string(), + Box::new( + |ui: &mut Ui, item: &InfoPanelEntry, stored_images: &mut IndexSet| { + Self::show_image_preview(ui, item, stored_images); + }, + ) as Box)>, + ); + supported_images.insert( + "jpeg".to_string(), + Box::new( + |ui: &mut Ui, item: &InfoPanelEntry, stored_images: &mut IndexSet| { + Self::show_image_preview(ui, item, stored_images); + }, + ) as Box)>, + ); + supported_images.insert( + "png".to_string(), + Box::new( + |ui: &mut Ui, item: &InfoPanelEntry, stored_images: &mut IndexSet| { + Self::show_image_preview(ui, item, stored_images); + }, + ) as Box)>, + ); + + Self { + panel_entry: None, + load_text_content: true, + text_content_max_chars: 1000, + loaded_file_name: PathBuf::new(), + supported_preview_files: supported_files, + supported_preview_images: supported_images, + additional_meta_files, + other_meta_data: IndexMap::default(), + stored_images: IndexSet::default(), + } + } +} + +impl InformationPanel { + fn show_image_preview( + ui: &mut Ui, + item: &InfoPanelEntry, + stored_images: &mut IndexSet, + ) { + stored_images.insert(format!("{}", item.directory_entry.as_path().display())); + let image = egui::Image::new(format!( + "file://{}", + item.directory_entry.as_path().display() + )); + ui.add(image); + } + + /// Adds support for previewing a custom file type. + /// + /// # Arguments + /// - `extension`: The file extension to support (e.g., "csv", "html"). + /// - `add_contents`: A closure defining how the file should be rendered in the UI. + /// + /// # Returns + /// The modified `InformationPanel` instance. + pub fn add_file_preview( + mut self, + extension: &str, + add_contents: impl FnMut(&mut Ui, &InfoPanelEntry) + 'static, + ) -> Self { + self.supported_preview_files + .insert(extension.to_string(), Box::new(add_contents)); + self + } + + /// Adds support for an additional metadata loader. + /// + /// # Arguments + /// - `extension`: The file extension to support (e.g., "png", "pdf"). + /// - `load_metadata`: A closure defining how the metadata should be loaded when the file is selected. + /// + /// # Returns + /// The modified `InformationPanel` instance. + pub fn add_metadata_loader( + mut self, + extension: &str, + load_metadata: impl FnMut(&mut IndexMap, &PathBuf) + 'static, + ) -> Self { + self.additional_meta_files + .insert(extension.to_string(), Box::new(load_metadata)); + self + } + + /// Reads a preview of the file if it is detected as a text file. + fn load_text_file_preview(path: PathBuf, max_chars: usize) -> io::Result { + let mut file = File::open(path)?; + let mut chunk = [0; 96]; // Temporary buffer + let mut buffer = String::new(); + + // Add the first chunk to the buffer as text + let mut total_read = 0; + + // Continue reading if needed + while total_read < max_chars { + let bytes_read = file.read(&mut chunk)?; + if bytes_read == 0 { + break; // End of file + } + let chars_read: String = String::from_utf8(chunk[..bytes_read].to_vec()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; + total_read += chars_read.len(); + buffer.push_str(&chars_read); + } + + Ok(buffer.to_string()) + } + + fn load_content(&self, path: PathBuf) -> Option { + if self.load_text_content { + Self::load_text_file_preview(path, self.text_content_max_chars).ok() + } else { + None + } + } + + /// Renders the Information Panel in the provided UI context. + /// + /// # Arguments + /// - `ui`: The UI context where the panel will be rendered. + /// - `file_dialog`: A reference to the current file dialog, which provides the active file entry. + pub fn ui(&mut self, ui: &mut Ui, file_dialog: &mut FileDialog) { + const SPACING_MULTIPLIER: f32 = 4.0; + + ui.label("Information"); + ui.separator(); + + // Display metadata in a grid format + let width = file_dialog.config_mut().right_panel_width.unwrap_or(100.0) / 2.0; + + if let Some(item) = file_dialog.active_entry() { + // load file content and additional metadata if it's a new file + self.load_meta_data(item); + + // show preview of selected item + self.display_preview(ui, item); + + let spacing = ui.ctx().style().spacing.item_spacing.y * SPACING_MULTIPLIER; + ui.separator(); + + ui.add_space(spacing); + + // show all metadata + self.display_meta_data(ui, file_dialog.get_window_id(), width, item); + } + } + + fn display_preview(&mut self, ui: &mut Ui, item: &DirectoryEntry) { + let size = Vec2 { + x: ui.available_width(), + y: ui.available_width() / 4.0 * 3.0, + }; + ui.allocate_ui_with_layout( + size, + Layout::centered_and_justified(Direction::TopDown), + |ui| { + if item.is_dir() { + // show folder icon + ui.label(egui::RichText::from(item.icon()).size(ui.available_width() / 3.0)); + } else { + // Display file content preview based on its extension + if let Some(ext) = item.as_path().extension().and_then(|ext| ext.to_str()) { + if let Some(panel_entry) = &self.panel_entry { + if let Some(preview_handler) = + self.supported_preview_files.get_mut(&ext.to_lowercase()) + { + preview_handler(ui, panel_entry); + } else if let Some(preview_handler) = + self.supported_preview_images.get_mut(&ext.to_lowercase()) + { + preview_handler(ui, panel_entry, &mut self.stored_images); + let number_of_stored_images = self.stored_images.len(); + if number_of_stored_images > 10 { + self.forget_last_stored_image(ui); + } + } else if let Some(mut content) = panel_entry.content() { + egui::ScrollArea::vertical() + .max_height(ui.available_height()) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut content).code_editor(), + ); + }); + } else { + // if no preview is available, show icon + ui.label( + egui::RichText::from(item.icon()) + .size(ui.available_width() / 3.0), + ); + } + } + } else { + // if no ext is available, show icon anyway + ui.label( + egui::RichText::from(item.icon()).size(ui.available_width() / 3.0), + ); + } + } + }, + ); + } + + fn forget_last_stored_image(&mut self, ui: &Ui) { + if let Some(last_image) = self.stored_images.first() { + ui.ctx() + .forget_image(format!("file://{last_image}").as_str()); + } + self.stored_images.shift_remove_index(0); + } + + /// removes all loaded preview images from the egui-loaders to reduce memory usage. + pub fn forget_all_stored_images(&mut self, ui: &Ui) { + for image in &self.stored_images { + ui.ctx().forget_image(format!("file://{image}").as_str()); + } + self.stored_images.clear(); + } + + fn load_meta_data(&mut self, item: &DirectoryEntry) { + let path_buf = item.to_path_buf(); + if self.loaded_file_name != path_buf { + self.loaded_file_name.clone_from(&path_buf); + // clear previous meta data + self.other_meta_data = IndexMap::default(); + if let Some(ext) = path_buf.extension() { + if let Some(ext_str) = ext.to_str() { + if let Some(load_meta_data) = self.additional_meta_files.get_mut(ext_str) { + // load metadata + load_meta_data(&mut self.other_meta_data, &path_buf); + } + } + } + let content = self.load_content(path_buf); + self.panel_entry = Some(InfoPanelEntry::new(item.clone())); + if let Some(panel_entry) = &mut self.panel_entry { + // load content + if panel_entry.content().is_none() { + *panel_entry.content_mut() = content; + } + } + } + } + + fn display_meta_data(&self, ui: &mut Ui, id: egui::Id, width: f32, item: &DirectoryEntry) { + egui::ScrollArea::vertical() + .id_salt(id.with("meta_data_scroll")) + .show(ui, |ui| { + egui::Grid::new(id.with("meta_data_grid")) + .num_columns(2) + .striped(true) + .min_col_width(width) + .max_col_width(width) + .show(ui, |ui| { + ui.label("Filename: "); + ui.label(item.file_name().to_string()); + ui.end_row(); + + if let Some(size) = item.metadata().size { + ui.label("File Size: "); + if item.is_file() { + ui.label(format_bytes(size)); + } else { + ui.label("NAN"); + } + ui.end_row(); + } + + if let Some(date) = item.metadata().created { + ui.label("Created: "); + let created: DateTime = date.into(); + ui.label(format!("{}", created.format("%d.%m.%Y, %H:%M:%S"))); + ui.end_row(); + } + + if let Some(date) = item.metadata().last_modified { + ui.label("Last Modified: "); + let modified: DateTime = date.into(); + ui.label(format!("{}", modified.format("%d.%m.%Y, %H:%M:%S"))); + ui.end_row(); + } + + // show additional metadata, if present + for (key, value) in self.other_meta_data.clone() { + ui.label(key); + ui.label(value); + ui.end_row(); + } + }); + }); + } +} + +/// Formats a file size (in bytes) into a human-readable string (e.g., KB, MB). +/// +/// # Arguments +/// - `bytes`: The file size in bytes. +/// +/// # Returns +/// A string representing the file size in an appropriate unit. +fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + const TB: u64 = GB * 1024; + + if bytes >= TB { + format!("{:.2} TB", bytes as f64 / TB as f64) + } else if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{bytes} B") + } +} diff --git a/src/lib.rs b/src/lib.rs index 600d9bf6..0aa52518 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -198,6 +198,8 @@ mod config; mod create_directory_dialog; mod data; mod file_dialog; +/// Information panel showing the preview and metadata of the selected item +pub mod information_panel; mod modals; pub use config::{