//! Modal dialogs such as message boxes and file pickers. use std::marker::PhantomData; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use std::{env, fs}; use figures::units::Lp; use parking_lot::Mutex; use crate::styles::components::{PrimaryColor, WidgetBackground}; use crate::styles::DynamicComponent; use crate::value::{Destination, Dynamic, Source}; use crate::widget::{MakeWidget, OnceCallback, SharedCallback, WidgetList}; use crate::widgets::button::{ButtonKind, ClickCounter}; use crate::widgets::input::InputValue; use crate::widgets::layers::{Modal, ModalHandle, ModalTarget}; use crate::widgets::Custom; use crate::ModifiersExt; #[cfg(feature = "native-dialogs")] mod native; #[derive(Clone, Debug)] struct MessageButtons { kind: MessageButtonsKind, affirmative: MessageButton, negative: Option, cancel: Option, } #[derive(Clone, Debug, Copy)] enum MessageButtonsKind { YesNo, OkCancel, } /// A button in a [`MessageBox`]. /// /// This type implements [`From`] for several types: /// /// - `String`, `&str`: A button with the string's contents as the caption that /// dismisses the message box. /// - `FnMut()` implementors: A button with the default caption given its /// context that invokes the closure when chosen. /// - `()`: The default button for this context. Equivalent to /// `MessageButton::default()`. /// /// To create a button with a custom caption that invokes a closure when chosen, /// use [`MessageButton::custom`]. #[derive(Clone, Debug, Default)] pub struct MessageButton { callback: OptionalCallback, caption: String, } impl MessageButton { /// Returns a button with a custom caption that invokes `on_click` when /// selected. pub fn custom(caption: impl Into, on_click: F) -> Self where F: FnOnce() + Send + 'static, { Self { callback: OptionalCallback(Arc::new(Mutex::new(Some(OnceCallback::new(move |()| { on_click(); }))))), caption: caption.into(), } } } impl From for MessageButton { fn from(value: String) -> Self { Self { callback: OptionalCallback::default(), caption: value, } } } impl From<&'_ String> for MessageButton { fn from(value: &'_ String) -> Self { Self::from(value.clone()) } } impl From<&'_ str> for MessageButton { fn from(value: &'_ str) -> Self { Self::from(value.to_string()) } } impl From for MessageButton where F: FnOnce() + Send + 'static, { fn from(value: F) -> Self { Self::custom(String::new(), value) } } impl From<()> for MessageButton { fn from(_value: ()) -> Self { Self::default() } } #[derive(Clone, Debug, Default)] struct OptionalCallback(Arc>>); impl OptionalCallback { fn invoke(&self) { if let Some(callback) = self.0.lock().take() { callback.invoke(()); } } } #[derive(Default, Clone, Eq, PartialEq, Copy, Debug)] enum MessageLevel { Error, Warning, #[default] Info, } /// A marker indicating a [`MessageBoxBuilder`] does not have a preference /// between a yes/no/cancel or an ok/cancel configuration. pub enum Undecided {} /// Specializes a [`MessageBoxBuilder`] for an Ok/Cancel dialog. pub enum OkCancel {} /// Specializes a [`MessageBoxBuilder`] for a Yes/No dialog. pub enum YesNoCancel {} /// A builder for a [`MessageBox`]. #[must_use] pub struct MessageBoxBuilder(MessageBox, PhantomData); impl MessageBoxBuilder { fn new(message: MessageBox) -> MessageBoxBuilder { Self(message, PhantomData) } /// Sets the explanation text and returns self. pub fn with_explanation(mut self, explanation: impl Into) -> Self { self.0.description = explanation.into(); self } /// Displays this message as a warning. /// /// When using native dialogs, not all platforms support this stylization. pub fn warning(mut self) -> Self { self.0.level = MessageLevel::Warning; self } /// Displays this message as an error. /// /// When using native dialogs, not all platforms support this stylization. pub fn error(mut self) -> Self { self.0.level = MessageLevel::Error; self } /// Adds a cancel button and returns self. pub fn with_cancel(mut self, cancel: impl Into) -> Self { self.0.buttons.cancel = Some(cancel.into()); self } /// Returns the completed message box. #[must_use] pub fn finish(self) -> MessageBox { self.0 } } impl MessageBoxBuilder { /// Sets the yes button and returns self. pub fn with_yes(self, yes: impl Into) -> MessageBoxBuilder { let Self(mut message, _) = self; message.buttons.kind = MessageButtonsKind::YesNo; message.buttons.affirmative = yes.into(); MessageBoxBuilder(message, PhantomData) } /// Sets the ok button and returns self. pub fn with_ok(self, ok: impl Into) -> MessageBoxBuilder { let Self(mut message, _) = self; message.buttons.affirmative = ok.into(); MessageBoxBuilder(message, PhantomData) } } impl MessageBoxBuilder { /// Sets the no button and returns self. pub fn with_no(mut self, no: impl Into) -> Self { self.0.buttons.negative = Some(no.into()); self } } impl MessageBoxBuilder {} /// A dialog that displays a message. #[derive(Debug, Clone)] pub struct MessageBox { level: MessageLevel, title: String, description: String, buttons: MessageButtons, } impl MessageBox { fn new(title: String, kind: MessageButtonsKind) -> Self { Self { level: MessageLevel::default(), title, description: String::default(), buttons: MessageButtons { kind, affirmative: MessageButton::default(), negative: None, cancel: None, }, } } /// Returns a builder for a dialog displaying `message`. pub fn build(message: impl Into) -> MessageBoxBuilder { MessageBoxBuilder::new(Self::new(message.into(), MessageButtonsKind::OkCancel)) } /// Returns a dialog displaying `message` with an `OK` button that dismisses /// the dialog. #[must_use] pub fn message(message: impl Into) -> Self { Self::build(message).finish() } /// Sets the explanation text and returns self. #[must_use] pub fn with_explanation(mut self, explanation: impl Into) -> Self { self.description = explanation.into(); self } /// Displays this message as a warning. /// /// When using native dialogs, not all platforms support this stylization. #[must_use] pub fn warning(mut self) -> Self { self.level = MessageLevel::Warning; self } /// Displays this message as an error. /// /// When using native dialogs, not all platforms support this stylization. #[must_use] pub fn error(mut self) -> Self { self.level = MessageLevel::Error; self } /// Adds a cancel button and returns self. #[must_use] pub fn with_cancel(mut self, cancel: impl Into) -> Self { self.buttons.cancel = Some(cancel.into()); self } /// Opens this dialog in the given target. /// /// A target can be a [`Modal`] layer, a /// [`ModalHandle`](crate::widgets::layers::ModalHandle), a /// [`WindowHandle`](crate::window::WindowHandle), or an /// [`App`](crate::App). pub fn open(&self, open_in: &impl OpenMessageBox) { open_in.open_message_box(self); } } /// A type that can open a [`MessageBox`] as a modal dialog. pub trait OpenMessageBox { /// Opens `message` as a modal dialog. fn open_message_box(&self, message: &MessageBox); } fn coalesce_empty<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.is_empty() { s2 } else { s1 } } impl OpenMessageBox for T where T: ModalTarget, { fn open_message_box(&self, message: &MessageBox) { let handle = self.new_handle(); let dialog = handle.build_dialog( message .title .as_str() .h5() .and(message.description.as_str()) .into_rows(), ); let (default_affirmative, default_negative) = match &message.buttons.kind { MessageButtonsKind::OkCancel => ("OK", None), MessageButtonsKind::YesNo => ("Yes", Some("No")), }; let on_ok = message.buttons.affirmative.callback.clone(); let mut dialog = dialog.with_default_button( coalesce_empty(&message.buttons.affirmative.caption, default_affirmative), move || on_ok.invoke(), ); if let (Some(negative), Some(default_negative)) = (&message.buttons.negative, default_negative) { let on_negative = negative.callback.clone(); dialog = dialog.with_button( coalesce_empty(&negative.caption, default_negative), move || { on_negative.invoke(); }, ); } if let Some(cancel) = &message.buttons.cancel { let on_cancel = cancel.callback.clone(); dialog .with_cancel_button(coalesce_empty(&cancel.caption, "Cancel"), move || { on_cancel.invoke(); }) .show(); } else { dialog.show(); } } } /// A dialog that can pick one or more files or directories. #[derive(Debug, Clone, Eq, PartialEq)] pub struct FilePicker { types: Vec, directory: Option, file_name: String, title: String, can_create_directories: Option, } impl Default for FilePicker { fn default() -> Self { Self::new() } } impl FilePicker { /// Returns a new file picker dialog. #[must_use] pub const fn new() -> Self { Self { types: Vec::new(), directory: None, file_name: String::new(), title: String::new(), can_create_directories: None, } } /// Sets the title of the dialog and returns self. #[must_use] pub fn with_title(mut self, title: impl Into) -> Self { self.title = title.into(); self } /// Sets the initial file name for the dialog and returns self. #[must_use] pub fn with_file_name(mut self, file_name: impl Into) -> Self { self.file_name = file_name.into(); self } /// Enables directory creation within the dialog and returns self. #[must_use] pub fn allowing_directory_creation(mut self, allowed: bool) -> Self { self.can_create_directories = Some(allowed); self } /// Adds the list of type filters to the dialog and returns self. /// /// These type filters are used for the dialog to only show related files /// and restrict what extensions are allowed to be picked. #[must_use] pub fn with_types(mut self, types: impl IntoIterator) -> Self where Type: Into, { self.types = types.into_iter().map(Into::into).collect(); self } /// Sets the initial directory for the dialog and returns self. #[must_use] pub fn with_initial_directory(mut self, directory: impl AsRef) -> Self { self.directory = Some(directory.as_ref().to_path_buf()); self } /// Shows a picker that selects a single file and invokes `on_dismiss` when /// the dialog is dismissed. pub fn pick_file(&self, pick_in: &impl PickFile, on_dismiss: Callback) where Callback: FnOnce(Option) + Send + 'static, { pick_in.pick_file(self, on_dismiss); } /// Shows a picker that creates a new file and invokes `on_dismiss` when the /// dialog is dismissed. pub fn save_file(&self, pick_in: &impl PickFile, on_dismiss: Callback) where Callback: FnOnce(Option) + Send + 'static, { pick_in.save_file(self, on_dismiss); } /// Shows a picker that selects one or more files and invokes `on_dismiss` /// when the dialog is dismissed. pub fn pick_files(&self, pick_in: &impl PickFile, on_dismiss: Callback) where Callback: FnOnce(Option>) + Send + 'static, { pick_in.pick_files(self, on_dismiss); } /// Shows a picker that selects a single folder/directory and invokes /// `on_dismiss` when the dialog is dismissed. pub fn pick_folder(&self, pick_in: &impl PickFile, on_dismiss: Callback) where Callback: FnOnce(Option) + Send + 'static, { pick_in.pick_folder(self, on_dismiss); } /// Shows a picker that selects one or more folders/directorys and invokes /// `on_dismiss` when the dialog is dismissed. pub fn pick_folders(&self, pick_in: &impl PickFile, on_dismiss: Callback) where Callback: FnOnce(Option>) + Send + 'static, { pick_in.pick_folders(self, on_dismiss); } } /// A file type filter used in a [`FilePicker`]. #[derive(Debug, Clone, Eq, PartialEq)] pub struct FileType { name: String, extensions: Vec, } impl FileType { /// Returns a new file type from the given name and list of file extensions. pub fn new( name: impl Into, extensions: impl IntoIterator, ) -> Self where Extension: Into, { Self { name: name.into(), extensions: extensions.into_iter().map(Into::into).collect(), } } /// Returns true if the given path matches this file type's extensions. #[must_use] pub fn matches(&self, path: &Path) -> bool { let Some(extension) = path.extension() else { return false; }; self.extensions.iter().any(|test| **test == *extension) } } impl From<(Name, [Extension; EXTENSIONS])> for FileType where Name: Into, Extension: Into, { fn from((name, extensions): (Name, [Extension; EXTENSIONS])) -> Self { Self::new(name, extensions) } } /// Shows a [`FilePicker`] in a given mode. pub trait PickFile { /// Shows a picker that selects a single file and invokes `on_dismiss` when /// the dialog is dismissed. fn pick_file(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option) + Send + 'static; /// Shows a picker that creates a new file and invokes `on_dismiss` when the /// dialog is dismissed. fn save_file(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option) + Send + 'static; /// Shows a picker that selects one or more files and invokes `on_dismiss` /// when the dialog is dismissed. fn pick_files(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option>) + Send + 'static; /// Shows a picker that selects a single folder/directory and invokes /// `on_dismiss` when the dialog is dismissed. fn pick_folder(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option) + Send + 'static; /// Shows a picker that selects one or more folders/directorys and invokes /// `on_dismiss` when the dialog is dismissed. fn pick_folders(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option>) + Send + 'static; } #[derive(Clone, Copy, Debug)] enum ModeKind { File, SaveFile, Files, Folder, Folders, } impl ModeKind { const fn is_multiple(self) -> bool { matches!(self, ModeKind::Files | ModeKind::Folders) } const fn is_file(self) -> bool { matches!(self, ModeKind::File | ModeKind::Files | ModeKind::SaveFile) } } enum ModeCallback { Single(OnceCallback>), Multiple(OnceCallback>>), } enum Mode { File(OnceCallback>), SaveFile(OnceCallback>), Files(OnceCallback>>), Folder(OnceCallback>), Folders(OnceCallback>>), } impl Mode { fn file(callback: Callback) -> Self where Callback: FnOnce(Option) + Send + 'static, { Self::File(OnceCallback::new(callback)) } fn save_file(callback: Callback) -> Self where Callback: FnOnce(Option) + Send + 'static, { Self::SaveFile(OnceCallback::new(callback)) } fn files(callback: Callback) -> Self where Callback: FnOnce(Option>) + Send + 'static, { Self::Files(OnceCallback::new(callback)) } fn folder(callback: Callback) -> Self where Callback: FnOnce(Option) + Send + 'static, { Self::Folder(OnceCallback::new(callback)) } fn folders(callback: Callback) -> Self where Callback: FnOnce(Option>) + Send + 'static, { Self::Folders(OnceCallback::new(callback)) } fn into_callback(self) -> ModeCallback { match self { Mode::File(once_callback) | Mode::SaveFile(once_callback) | Mode::Folder(once_callback) => ModeCallback::Single(once_callback), Mode::Files(once_callback) | Mode::Folders(once_callback) => { ModeCallback::Multiple(once_callback) } } } fn kind(&self) -> ModeKind { match self { Mode::File(_) => ModeKind::File, Mode::SaveFile(_) => ModeKind::SaveFile, Mode::Files(_) => ModeKind::Files, Mode::Folder(_) => ModeKind::Folder, Mode::Folders(_) => ModeKind::Folders, } } } impl PickFile for Modal { fn pick_file(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option) + Send + 'static, { let modal = self.clone(); let handle = self.new_handle(); handle.present(FilePickerWidget { handle: handle.clone(), picker: picker.clone(), mode: Mode::file(move |result| { modal.dismiss(); callback(result); }), }); } fn save_file(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option) + Send + 'static, { let modal = self.clone(); let handle = self.new_handle(); handle.present(FilePickerWidget { handle: handle.clone(), picker: picker.clone(), mode: Mode::save_file(move |result| { modal.dismiss(); callback(result); }), }); } fn pick_files(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option>) + Send + 'static, { let modal = self.clone(); let handle = self.new_handle(); handle.present(FilePickerWidget { handle: handle.clone(), picker: picker.clone(), mode: Mode::files(move |result| { modal.dismiss(); callback(result); }), }); } fn pick_folder(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option) + Send + 'static, { let modal = self.clone(); let handle = self.new_handle(); handle.present(FilePickerWidget { handle: handle.clone(), picker: picker.clone(), mode: Mode::folder(move |result| { modal.dismiss(); callback(result); }), }); } fn pick_folders(&self, picker: &FilePicker, callback: Callback) where Callback: FnOnce(Option>) + Send + 'static, { let modal = self.clone(); let handle = self.new_handle(); handle.present(FilePickerWidget { handle: handle.clone(), picker: picker.clone(), mode: Mode::folders(move |result| { modal.dismiss(); callback(result); }), }); } } struct FilePickerWidget { handle: ModalHandle, picker: FilePicker, mode: Mode, } impl MakeWidget for FilePickerWidget { #[allow(clippy::too_many_lines)] fn make_widget(self) -> crate::widget::WidgetInstance { let kind = self.mode.kind(); let callback = Arc::new(Mutex::new(Some(self.mode.into_callback()))); let title = if self.picker.title.is_empty() { match kind { ModeKind::File => "Select a file", ModeKind::SaveFile => "Save file", ModeKind::Files => "Select one or more files", ModeKind::Folder => "Select a folder", ModeKind::Folders => "Select one or more folders", } } else { &self.picker.title }; let caption = match kind { ModeKind::File | ModeKind::Files | ModeKind::Folder | ModeKind::Folders => "Select", ModeKind::SaveFile => "Save", }; let chosen_paths = Dynamic::>::default(); let confirm_enabled = chosen_paths.map_each(move |paths| { !paths.is_empty() && paths.iter().all(|p| p.is_file() == kind.is_file()) }); let browsing_directory = Dynamic::new( self.picker .directory .or_else(|| env::current_dir().ok()) .or_else(|| { env::current_exe() .ok() .and_then(|exe| exe.parent().map(Path::to_path_buf)) }) .unwrap_or_default(), ); let current_directory_files = browsing_directory.map_each(|dir| { let mut children = Vec::new(); match fs::read_dir(dir) { Ok(entries) => { for entry in entries.filter_map(Result::ok) { let name = entry.file_name().to_string_lossy().into_owned(); children.push((name, entry.path())); } } Err(err) => return Err(format!("Error reading directory: {err}")), } Ok(children) }); let multi_click_threshold = Dynamic::new(Duration::from_millis(500)); let choose_file = SharedCallback::new({ let chosen_paths = chosen_paths.clone(); let callback = callback.clone(); let types = self.picker.types.clone(); move |()| { let chosen_paths = chosen_paths.get(); let installed_callback = callback.lock().take(); match installed_callback { Some(ModeCallback::Single(cb)) => { let mut chosen_path = chosen_paths.into_iter().next(); if let Some(chosen_path_mut) = &mut chosen_path { if matches!(kind, ModeKind::SaveFile) { if !types.iter().any(|t| t.matches(chosen_path_mut)) { if let Some(extension) = types.first().and_then(|ty| ty.extensions.first()) { let path = chosen_path_mut.as_mut_os_string(); path.push("."); path.push(extension); } } if chosen_path_mut.exists() { *callback.lock() = Some(ModeCallback::Single(cb)); let name = chosen_path_mut .file_name() .map(|name| name.to_string_lossy()) .unwrap_or_default(); MessageBox::build("Confirm Overwrite") .with_explanation( format!("A file named \"{name}\" already exists. Do you want to overwrite the existing file?") ) .with_yes({ let callback = callback.clone(); move || { let Some(ModeCallback::Single(cb)) = callback.lock().take() else { unreachable!("re-set above"); }; cb.invoke(chosen_path.clone()); } }) .with_no(()) .finish() .open(&self.handle); return; } } } cb.invoke(chosen_path); } Some(ModeCallback::Multiple(cb)) => { cb.invoke(Some(chosen_paths)); } None => {} } } }); let file_list = current_directory_files .map_each({ let chosen_paths = chosen_paths.clone(); let allowed_types = self.picker.types.clone(); let multi_click_threshold = multi_click_threshold.clone(); let browsing_directory = browsing_directory.clone(); let choose_file = choose_file.clone(); move |files| match files { Ok(files) => files .iter() .filter(|(name, path)| { !name.starts_with('.') && path.is_dir() || (kind.is_file() && allowed_types.iter().all(|ty| ty.matches(path))) }) .map({ |(name, full_path)| { let selected = chosen_paths.map_each({ let full_path = full_path.clone(); move |chosen| chosen.contains(&full_path) }); name.align_left() .into_button() .kind(ButtonKind::Transparent) .on_click({ let mut counter = ClickCounter::new(multi_click_threshold.clone(), { let browsing_directory = browsing_directory.clone(); let choose_file = choose_file.clone(); let full_path = full_path.clone(); move |click_count, _| { if click_count == 2 { if full_path.is_dir() { browsing_directory .set(full_path.clone()); } else { choose_file.invoke(()); } } } }) .with_maximum(2); let chosen_paths = chosen_paths.clone(); let full_path = full_path.clone(); move |click| { if kind.is_multiple() && click.map_or(false, |click| { click.modifiers.state().primary() }) { let mut paths = chosen_paths.lock(); let mut removed = false; paths.retain(|p| { if p == &full_path { removed = true; false } else { true } }); if !removed { paths.push(full_path.clone()); } } else { let mut paths = chosen_paths.lock(); paths.clear(); paths.push(full_path.clone()); } counter.click(click); } }) .with_dynamic( &WidgetBackground, DynamicComponent::new(move |ctx| { if selected.get_tracking_invalidate(ctx) { Some(ctx.get(&PrimaryColor).into()) } else { None } }), ) } }) .collect::() .into_rows() .make_widget(), Err(err) => err.make_widget(), } }) .vertical_scroll() .expand(); let file_ui = if matches!(kind, ModeKind::SaveFile) { let name = Dynamic::::default(); let name_weak = name.downgrade(); name.set_source(chosen_paths.for_each(move |paths| { if paths.len() == 1 && paths[0].is_file() { if let Some(path_name) = paths[0] .file_name() .map(|name| name.to_string_lossy().into_owned()) { if let Some(name) = name_weak.upgrade() { name.set(path_name); } } } })); let browsing_directory = browsing_directory.clone(); let chosen_paths = chosen_paths.clone(); name.for_each(move |name| { let Ok(mut paths) = chosen_paths.try_lock() else { return; }; paths.clear(); paths.push(browsing_directory.get().join(name)); }) .persist(); file_list.and(name.into_input()).into_rows().make_widget() } else { file_list.make_widget() }; let click_duration_probe = Custom::empty().on_mounted({ move |ctx| multi_click_threshold.set(ctx.cushy().multi_click_threshold()) }); title .and(click_duration_probe) .into_columns() .and(file_ui.width(Lp::inches(6)).height(Lp::inches(4))) .and( "Cancel" .into_button() .on_click({ let mode = callback.clone(); move |_| match mode.lock().take() { Some(ModeCallback::Single(cb)) => cb.invoke(None), Some(ModeCallback::Multiple(cb)) => { cb.invoke(None); } None => {} } }) .into_escape() .and( caption .into_button() .on_click(move |_| choose_file.invoke(())) .into_default() .with_enabled(confirm_enabled), ) .into_columns() .align_right(), ) .into_rows() .contain() .make_widget() } }